mirror of https://github.com/portainer/portainer
feat(schedules): add the ability to update a schedule script (#2438)
parent
695c28d4f8
commit
807c830db0
|
@ -5,7 +5,6 @@ import (
|
|||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
|
@ -322,16 +321,15 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
|
|||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
// GetScheduleFolder returns the absolute path on the FS for a schedule based
|
||||
// GetScheduleFolder returns the absolute path on the filesystem for a schedule based
|
||||
// on its identifier.
|
||||
func (service *Service) GetScheduleFolder(scheduleIdentifier portainer.ScheduleID) string {
|
||||
return path.Join(service.fileStorePath, ScheduleStorePath, strconv.Itoa(int(scheduleIdentifier)))
|
||||
func (service *Service) GetScheduleFolder(identifier string) string {
|
||||
return path.Join(service.fileStorePath, ScheduleStorePath, identifier)
|
||||
}
|
||||
|
||||
// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreScheduledJobFileFromBytes(scheduleIdentifier portainer.ScheduleID, data []byte) (string, error) {
|
||||
identifier := strconv.Itoa(int(scheduleIdentifier))
|
||||
func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) {
|
||||
scheduleStorePath := path.Join(ScheduleStorePath, identifier)
|
||||
err := service.createDirectoryInStore(scheduleStorePath)
|
||||
if err != nil {
|
||||
|
|
|
@ -35,6 +35,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/schedules/{id}",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete)
|
||||
|
||||
h.Handle("/schedules/{id}/file",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package schedules
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
|
@ -138,7 +139,7 @@ func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Re
|
|||
func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) {
|
||||
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
|
||||
|
||||
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(scheduleIdentifier, file)
|
||||
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package schedules
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
|
@ -27,7 +28,7 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *
|
|||
return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")}
|
||||
}
|
||||
|
||||
scheduleFolder := handler.FileService.GetScheduleFolder(portainer.ScheduleID(scheduleID))
|
||||
scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID))
|
||||
err = handler.FileService.RemoveDirectory(scheduleFolder)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
package schedules
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type scheduleFileResponse struct {
|
||||
ScheduleFileContent string `json:"ScheduleFileContent"`
|
||||
}
|
||||
|
||||
// GET request on /api/schedules/:id/file
|
||||
func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
||||
}
|
||||
|
||||
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if schedule.JobType != portainer.ScriptExecutionJobType {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")}
|
||||
}
|
||||
|
||||
scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err}
|
||||
}
|
||||
|
||||
return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)})
|
||||
}
|
|
@ -2,6 +2,7 @@ package schedules
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
|
@ -15,6 +16,7 @@ type scheduleUpdatePayload struct {
|
|||
Image *string
|
||||
CronExpression *string
|
||||
Endpoints []portainer.EndpointID
|
||||
FileContent *string
|
||||
}
|
||||
|
||||
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
|
||||
|
@ -41,8 +43,16 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
}
|
||||
|
||||
updateJobSchedule := updateSchedule(schedule, &payload)
|
||||
if updateJobSchedule {
|
||||
|
||||
if payload.FileContent != nil {
|
||||
_, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err}
|
||||
}
|
||||
updateJobSchedule = true
|
||||
}
|
||||
|
||||
if updateJobSchedule {
|
||||
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
|
||||
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
|
||||
err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner)
|
||||
|
|
|
@ -654,8 +654,8 @@ type (
|
|||
LoadKeyPair() ([]byte, []byte, error)
|
||||
WriteJSONToFile(path string, content interface{}) error
|
||||
FileExists(path string) (bool, error)
|
||||
StoreScheduledJobFileFromBytes(scheduleIdentifier ScheduleID, data []byte) (string, error)
|
||||
GetScheduleFolder(scheduleIdentifier ScheduleID) string
|
||||
StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
|
||||
GetScheduleFolder(identifier string) string
|
||||
}
|
||||
|
||||
// GitService represents a service for managing Git
|
||||
|
|
|
@ -55,14 +55,14 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !image-input -->
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
|
||||
<code>/host</code> folder.
|
||||
</span>
|
||||
</div>
|
||||
<!-- execution-method -->
|
||||
<div ng-if="!$ctrl.model.Id">
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
|
||||
<code>/host</code> folder.
|
||||
</span>
|
||||
</div>
|
||||
<!-- execution-method -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Job content
|
||||
</div>
|
||||
|
@ -91,46 +91,46 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !execution-method -->
|
||||
<!-- web-editor -->
|
||||
<div ng-show="$ctrl.model.Job.Method === 'editor'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="execute-schedule-editor"
|
||||
placeholder="# Define or paste the content of your script file here"
|
||||
on-change="$ctrl.editorUpdate"
|
||||
value="$ctrl.model.Job.FileContent"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !execution-method -->
|
||||
<!-- web-editor -->
|
||||
<div ng-show="$ctrl.model.Job.Method === 'editor'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="execute-schedule-editor"
|
||||
placeholder="# Define or paste the content of your script file here"
|
||||
on-change="$ctrl.editorUpdate"
|
||||
value="$ctrl.model.Job.FileContent"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
<!-- upload -->
|
||||
<div ng-show="$ctrl.model.Job.Method === 'upload'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Upload
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can upload a script file from your computer.
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
<!-- upload -->
|
||||
<div ng-show="$ctrl.model.Job.Method === 'upload'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Upload
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can upload a script file from your computer.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.model.Job.File">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ $ctrl.model.Job.File.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.model.Job.File" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.model.Job.File">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ $ctrl.model.Job.File.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.model.Job.File" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Target endpoints
|
||||
</div>
|
||||
|
|
|
@ -27,6 +27,8 @@ function ScheduleModel(data) {
|
|||
function ScriptExecutionJobModel(data) {
|
||||
this.Image = data.Image;
|
||||
this.Endpoints = data.Endpoints;
|
||||
this.FileContent = '';
|
||||
this.Method = 'editor';
|
||||
}
|
||||
|
||||
function ScheduleCreateRequest(model) {
|
||||
|
@ -44,4 +46,5 @@ function ScheduleUpdateRequest(model) {
|
|||
this.CronExpression = model.CronExpression;
|
||||
this.Image = model.Job.Image;
|
||||
this.Endpoints = model.Job.Endpoints;
|
||||
this.FileContent = model.Job.FileContent;
|
||||
}
|
||||
|
|
|
@ -2,11 +2,12 @@ angular.module('portainer.app')
|
|||
.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES',
|
||||
function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_SCHEDULES + '/:id', {}, {
|
||||
return $resource(API_ENDPOINT_SCHEDULES + '/:id/:action', {}, {
|
||||
create: { method: 'POST' },
|
||||
query: { method: 'GET', isArray: true },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
update: { method: 'PUT', params: { id: '@id' } },
|
||||
remove: { method: 'DELETE', params: { id: '@id'} }
|
||||
remove: { method: 'DELETE', params: { id: '@id'} },
|
||||
file: { method: 'GET', params: { id : '@id', action: 'file' } }
|
||||
});
|
||||
}]);
|
||||
|
|
|
@ -55,5 +55,9 @@ function ScheduleService($q, Schedules, FileUploadService) {
|
|||
return Schedules.remove({ id: scheduleId }).$promise;
|
||||
};
|
||||
|
||||
service.getScriptFile = function(scheduleId) {
|
||||
return Schedules.file({ id: scheduleId }).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
}]);
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<schedule-form
|
||||
ng-if="schedule"
|
||||
model="schedule"
|
||||
endpoints="endpoints"
|
||||
groups="groups"
|
||||
|
|
|
@ -27,6 +27,7 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
|
|||
|
||||
function initView() {
|
||||
var id = $transition$.params().id;
|
||||
var schedule = null;
|
||||
|
||||
$q.all({
|
||||
schedule: ScheduleService.schedule(id),
|
||||
|
@ -34,9 +35,15 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
|
|||
groups: GroupService.groups()
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.schedule = data.schedule;
|
||||
schedule = data.schedule;
|
||||
$scope.endpoints = data.endpoints;
|
||||
$scope.groups = data.groups;
|
||||
|
||||
return ScheduleService.getScriptFile(schedule.Id);
|
||||
})
|
||||
.then(function success(data) {
|
||||
schedule.Job.FileContent = data.ScheduleFileContent;
|
||||
$scope.schedule = schedule;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
|
||||
|
|
Loading…
Reference in New Issue