feat(schedules): add the ability to update a schedule script (#2438)

pull/2445/head
Anthony Lapenna 2018-11-07 17:19:10 +13:00 committed by GitHub
parent 695c28d4f8
commit 807c830db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 124 additions and 56 deletions

View File

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"io/ioutil" "io/ioutil"
"strconv"
"github.com/portainer/portainer" "github.com/portainer/portainer"
@ -322,16 +321,15 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
return block.Bytes, nil 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. // on its identifier.
func (service *Service) GetScheduleFolder(scheduleIdentifier portainer.ScheduleID) string { func (service *Service) GetScheduleFolder(identifier string) string {
return path.Join(service.fileStorePath, ScheduleStorePath, strconv.Itoa(int(scheduleIdentifier))) return path.Join(service.fileStorePath, ScheduleStorePath, identifier)
} }
// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes. // 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. // It returns the path to the folder where the file is stored.
func (service *Service) StoreScheduledJobFileFromBytes(scheduleIdentifier portainer.ScheduleID, data []byte) (string, error) { func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) {
identifier := strconv.Itoa(int(scheduleIdentifier))
scheduleStorePath := path.Join(ScheduleStorePath, identifier) scheduleStorePath := path.Join(ScheduleStorePath, identifier)
err := service.createDirectoryInStore(scheduleStorePath) err := service.createDirectoryInStore(scheduleStorePath)
if err != nil { if err != nil {

View File

@ -35,6 +35,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut)
h.Handle("/schedules/{id}", h.Handle("/schedules/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) 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 return h
} }

View File

@ -3,6 +3,7 @@ package schedules
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/asaskevich/govalidator" "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) { func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -3,6 +3,7 @@ package schedules
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "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")} 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) err = handler.FileService.RemoveDirectory(scheduleFolder)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}

View File

@ -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)})
}

View File

@ -2,6 +2,7 @@ package schedules
import ( import (
"net/http" "net/http"
"strconv"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
@ -15,6 +16,7 @@ type scheduleUpdatePayload struct {
Image *string Image *string
CronExpression *string CronExpression *string
Endpoints []portainer.EndpointID Endpoints []portainer.EndpointID
FileContent *string
} }
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { 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) 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) jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner) err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner)

View File

@ -654,8 +654,8 @@ type (
LoadKeyPair() ([]byte, []byte, error) LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error WriteJSONToFile(path string, content interface{}) error
FileExists(path string) (bool, error) FileExists(path string) (bool, error)
StoreScheduledJobFileFromBytes(scheduleIdentifier ScheduleID, data []byte) (string, error) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
GetScheduleFolder(scheduleIdentifier ScheduleID) string GetScheduleFolder(identifier string) string
} }
// GitService represents a service for managing Git // GitService represents a service for managing Git

View File

@ -55,14 +55,14 @@
</div> </div>
</div> </div>
<!-- !image-input --> <!-- !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 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"> <div class="col-sm-12 form-section-title">
Job content Job content
</div> </div>
@ -91,46 +91,46 @@
</div> </div>
</div> </div>
</div> </div>
<!-- !execution-method --> </div>
<!-- web-editor --> <!-- !execution-method -->
<div ng-show="$ctrl.model.Job.Method === 'editor'"> <!-- web-editor -->
<div class="col-sm-12 form-section-title"> <div ng-show="$ctrl.model.Job.Method === 'editor'">
Web editor <div class="col-sm-12 form-section-title">
</div> Web editor
<div class="form-group"> </div>
<div class="col-sm-12"> <div class="form-group">
<code-editor <div class="col-sm-12">
identifier="execute-schedule-editor" <code-editor
placeholder="# Define or paste the content of your script file here" identifier="execute-schedule-editor"
on-change="$ctrl.editorUpdate" placeholder="# Define or paste the content of your script file here"
value="$ctrl.model.Job.FileContent" on-change="$ctrl.editorUpdate"
></code-editor> value="$ctrl.model.Job.FileContent"
</div> ></code-editor>
</div> </div>
</div> </div>
<!-- !web-editor --> </div>
<!-- upload --> <!-- !web-editor -->
<div ng-show="$ctrl.model.Job.Method === 'upload'"> <!-- upload -->
<div class="col-sm-12 form-section-title"> <div ng-show="$ctrl.model.Job.Method === 'upload'">
Upload <div class="col-sm-12 form-section-title">
</div> Upload
<div class="form-group"> </div>
<span class="col-sm-12 text-muted small"> <div class="form-group">
You can upload a script file from your computer. <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> </span>
</div> </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> </div>
<!-- !upload -->
</div> </div>
<!-- !upload -->
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Target endpoints Target endpoints
</div> </div>

View File

@ -27,6 +27,8 @@ function ScheduleModel(data) {
function ScriptExecutionJobModel(data) { function ScriptExecutionJobModel(data) {
this.Image = data.Image; this.Image = data.Image;
this.Endpoints = data.Endpoints; this.Endpoints = data.Endpoints;
this.FileContent = '';
this.Method = 'editor';
} }
function ScheduleCreateRequest(model) { function ScheduleCreateRequest(model) {
@ -44,4 +46,5 @@ function ScheduleUpdateRequest(model) {
this.CronExpression = model.CronExpression; this.CronExpression = model.CronExpression;
this.Image = model.Job.Image; this.Image = model.Job.Image;
this.Endpoints = model.Job.Endpoints; this.Endpoints = model.Job.Endpoints;
this.FileContent = model.Job.FileContent;
} }

View File

@ -2,11 +2,12 @@ angular.module('portainer.app')
.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES', .factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES',
function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) {
'use strict'; 'use strict';
return $resource(API_ENDPOINT_SCHEDULES + '/:id', {}, { return $resource(API_ENDPOINT_SCHEDULES + '/:id/:action', {}, {
create: { method: 'POST' }, create: { method: 'POST' },
query: { method: 'GET', isArray: true }, query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } }, get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', 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' } }
}); });
}]); }]);

View File

@ -55,5 +55,9 @@ function ScheduleService($q, Schedules, FileUploadService) {
return Schedules.remove({ id: scheduleId }).$promise; return Schedules.remove({ id: scheduleId }).$promise;
}; };
service.getScriptFile = function(scheduleId) {
return Schedules.file({ id: scheduleId }).$promise;
};
return service; return service;
}]); }]);

View File

@ -14,6 +14,7 @@
<rd-widget> <rd-widget>
<rd-widget-body> <rd-widget-body>
<schedule-form <schedule-form
ng-if="schedule"
model="schedule" model="schedule"
endpoints="endpoints" endpoints="endpoints"
groups="groups" groups="groups"

View File

@ -27,6 +27,7 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
function initView() { function initView() {
var id = $transition$.params().id; var id = $transition$.params().id;
var schedule = null;
$q.all({ $q.all({
schedule: ScheduleService.schedule(id), schedule: ScheduleService.schedule(id),
@ -34,9 +35,15 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
groups: GroupService.groups() groups: GroupService.groups()
}) })
.then(function success(data) { .then(function success(data) {
$scope.schedule = data.schedule; schedule = data.schedule;
$scope.endpoints = data.endpoints; $scope.endpoints = data.endpoints;
$scope.groups = data.groups; $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) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); Notifications.error('Failure', err, 'Unable to retrieve endpoint list');