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/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 {

View File

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

View File

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

View File

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

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 (
"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)

View File

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

View File

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

View File

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

View File

@ -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' } }
});
}]);

View 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;
}]);

View File

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

View File

@ -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');