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/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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 (
|
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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' } }
|
||||||
});
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -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;
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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');
|
||||||
|
|
Loading…
Reference in New Issue