mirror of
https://github.com/portainer/portainer.git
synced 2025-07-23 15:29:42 +02:00
feat(schedules): add retry policy to script schedules (#2445)
This commit is contained in:
parent
e7ab057c81
commit
a2d9f591a7
9 changed files with 160 additions and 44 deletions
|
@ -2,6 +2,7 @@ package cron
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
)
|
)
|
||||||
|
@ -46,6 +47,7 @@ func (runner *ScriptExecutionJobRunner) Run() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targets := make([]*portainer.Endpoint, 0)
|
||||||
for _, endpointID := range runner.job.Endpoints {
|
for _, endpointID := range runner.job.Endpoints {
|
||||||
endpoint, err := runner.context.endpointService.Endpoint(endpointID)
|
endpoint, err := runner.context.endpointService.Endpoint(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -53,11 +55,32 @@ func (runner *ScriptExecutionJobRunner) Run() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = runner.context.jobService.Execute(endpoint, "", runner.job.Image, scriptFile)
|
targets = append(targets, endpoint)
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("scheduled job error (script execution). Unable to execute scrtip (endpoint=%s) (err=%s)\n", endpoint.Name, err)
|
|
||||||
|
runner.executeAndRetry(targets, scriptFile, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) {
|
||||||
|
retryTargets := make([]*portainer.Endpoint, 0)
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
err := runner.context.jobService.Execute(endpoint, "", runner.job.Image, script)
|
||||||
|
if err == portainer.ErrUnableToPingEndpoint {
|
||||||
|
retryTargets = append(retryTargets, endpoint)
|
||||||
|
} else if err != nil {
|
||||||
|
log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryCount++
|
||||||
|
if retryCount >= runner.job.RetryCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Duration(runner.job.RetryInterval) * time.Second)
|
||||||
|
|
||||||
|
runner.executeAndRetry(retryTargets, script, retryCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScheduleID returns the schedule identifier associated to the runner
|
// GetScheduleID returns the schedule identifier associated to the runner
|
||||||
|
|
|
@ -41,6 +41,11 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
|
_, err = cli.Ping(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return portainer.ErrUnableToPingEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
err = pullImage(cli, image)
|
err = pullImage(cli, image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -88,6 +88,11 @@ const (
|
||||||
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
|
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Docker errors.
|
||||||
|
const (
|
||||||
|
ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint")
|
||||||
|
)
|
||||||
|
|
||||||
// Error represents an application error.
|
// Error represents an application error.
|
||||||
type Error string
|
type Error string
|
||||||
|
|
||||||
|
|
|
@ -14,23 +14,27 @@ import (
|
||||||
"github.com/portainer/portainer/cron"
|
"github.com/portainer/portainer/cron"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scheduleFromFilePayload struct {
|
type scheduleCreateFromFilePayload struct {
|
||||||
Name string
|
Name string
|
||||||
Image string
|
Image string
|
||||||
CronExpression string
|
CronExpression string
|
||||||
Endpoints []portainer.EndpointID
|
Endpoints []portainer.EndpointID
|
||||||
File []byte
|
File []byte
|
||||||
|
RetryCount int
|
||||||
|
RetryInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
type scheduleFromFileContentPayload struct {
|
type scheduleCreateFromFileContentPayload struct {
|
||||||
Name string
|
Name string
|
||||||
CronExpression string
|
CronExpression string
|
||||||
Image string
|
Image string
|
||||||
Endpoints []portainer.EndpointID
|
Endpoints []portainer.EndpointID
|
||||||
FileContent string
|
FileContent string
|
||||||
|
RetryCount int
|
||||||
|
RetryInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *scheduleFromFilePayload) Validate(r *http.Request) error {
|
func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error {
|
||||||
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("Invalid name")
|
return errors.New("Invalid name")
|
||||||
|
@ -62,10 +66,16 @@ func (payload *scheduleFromFilePayload) Validate(r *http.Request) error {
|
||||||
}
|
}
|
||||||
payload.File = file
|
payload.File = file
|
||||||
|
|
||||||
|
retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true)
|
||||||
|
payload.RetryCount = retryCount
|
||||||
|
|
||||||
|
retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true)
|
||||||
|
payload.RetryInterval = retryInterval
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error {
|
func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
if govalidator.IsNull(payload.Name) {
|
if govalidator.IsNull(payload.Name) {
|
||||||
return portainer.Error("Invalid schedule name")
|
return portainer.Error("Invalid schedule name")
|
||||||
}
|
}
|
||||||
|
@ -86,6 +96,10 @@ func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
return portainer.Error("Invalid script file content")
|
return portainer.Error("Invalid script file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.RetryCount != 0 && payload.RetryInterval == 0 {
|
||||||
|
return portainer.Error("RetryInterval must be set")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,71 +121,100 @@ func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
var payload scheduleFromFileContentPayload
|
var payload scheduleCreateFromFileContentPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, []byte(payload.FileContent))
|
schedule := handler.createScheduleObjectFromFileContentPayload(&payload)
|
||||||
|
|
||||||
|
err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
return response.JSON(w, schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
payload := &scheduleFromFilePayload{}
|
payload := &scheduleCreateFromFilePayload{}
|
||||||
err := payload.Validate(r)
|
err := payload.Validate(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, payload.File)
|
schedule := handler.createScheduleObjectFromFilePayload(payload)
|
||||||
|
|
||||||
|
err = handler.addAndPersistSchedule(schedule, payload.File)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
return response.JSON(w, schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) {
|
func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule {
|
||||||
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
|
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
|
||||||
|
|
||||||
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
job := &portainer.ScriptExecutionJob{
|
job := &portainer.ScriptExecutionJob{
|
||||||
Endpoints: endpoints,
|
Endpoints: payload.Endpoints,
|
||||||
Image: image,
|
Image: payload.Image,
|
||||||
ScriptPath: scriptPath,
|
ScheduleID: scheduleIdentifier,
|
||||||
ScheduleID: scheduleIdentifier,
|
RetryCount: payload.RetryCount,
|
||||||
|
RetryInterval: payload.RetryInterval,
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule := &portainer.Schedule{
|
schedule := &portainer.Schedule{
|
||||||
ID: scheduleIdentifier,
|
ID: scheduleIdentifier,
|
||||||
Name: name,
|
Name: payload.Name,
|
||||||
CronExpression: cronExpression,
|
CronExpression: payload.CronExpression,
|
||||||
JobType: portainer.ScriptExecutionJobType,
|
JobType: portainer.ScriptExecutionJobType,
|
||||||
ScriptExecutionJob: job,
|
ScriptExecutionJob: job,
|
||||||
Created: time.Now().Unix(),
|
Created: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule {
|
||||||
|
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
|
||||||
|
|
||||||
|
job := &portainer.ScriptExecutionJob{
|
||||||
|
Endpoints: payload.Endpoints,
|
||||||
|
Image: payload.Image,
|
||||||
|
ScheduleID: scheduleIdentifier,
|
||||||
|
RetryCount: payload.RetryCount,
|
||||||
|
RetryInterval: payload.RetryInterval,
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule := &portainer.Schedule{
|
||||||
|
ID: scheduleIdentifier,
|
||||||
|
Name: payload.Name,
|
||||||
|
CronExpression: payload.CronExpression,
|
||||||
|
JobType: portainer.ScriptExecutionJobType,
|
||||||
|
ScriptExecutionJob: job,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error {
|
||||||
|
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule.ScriptExecutionJob.ScriptPath = scriptPath
|
||||||
|
|
||||||
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
|
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
|
||||||
jobRunner := cron.NewScriptExecutionJobRunner(job, jobContext)
|
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
|
||||||
|
|
||||||
err = handler.JobScheduler.CreateSchedule(schedule, jobRunner)
|
err = handler.JobScheduler.CreateSchedule(schedule, jobRunner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.ScheduleService.CreateSchedule(schedule)
|
return handler.ScheduleService.CreateSchedule(schedule)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,8 @@ type scheduleUpdatePayload struct {
|
||||||
CronExpression *string
|
CronExpression *string
|
||||||
Endpoints []portainer.EndpointID
|
Endpoints []portainer.EndpointID
|
||||||
FileContent *string
|
FileContent *string
|
||||||
|
RetryCount *int
|
||||||
|
RetryInterval *int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
|
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -91,5 +93,15 @@ func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload
|
||||||
updateJobSchedule = true
|
updateJobSchedule = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.RetryCount != nil {
|
||||||
|
schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount
|
||||||
|
updateJobSchedule = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.RetryInterval != nil {
|
||||||
|
schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval
|
||||||
|
updateJobSchedule = true
|
||||||
|
}
|
||||||
|
|
||||||
return updateJobSchedule
|
return updateJobSchedule
|
||||||
}
|
}
|
||||||
|
|
|
@ -228,10 +228,12 @@ type (
|
||||||
|
|
||||||
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
|
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
|
||||||
ScriptExecutionJob struct {
|
ScriptExecutionJob struct {
|
||||||
ScheduleID ScheduleID `json:"ScheduleId"`
|
ScheduleID ScheduleID `json:"ScheduleId"`
|
||||||
Endpoints []EndpointID
|
Endpoints []EndpointID
|
||||||
Image string
|
Image string
|
||||||
ScriptPath string
|
ScriptPath string
|
||||||
|
RetryCount int
|
||||||
|
RetryInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotJob represents a scheduled job that can create endpoint snapshots
|
// SnapshotJob represents a scheduled job that can create endpoint snapshots
|
||||||
|
|
|
@ -42,8 +42,8 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- image-input -->
|
<!-- image-input -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule_image" class="col-sm-1 control-label text-left">Image</label>
|
<label for="schedule_image" class="col-sm-2 control-label text-left">Image</label>
|
||||||
<div class="col-sm-11">
|
<div class="col-sm-10">
|
||||||
<input type="text" class="form-control" ng-model="$ctrl.model.Job.Image" id="schedule_image" name="schedule_image" placeholder="e.g. ubuntu:latest" required>
|
<input type="text" class="form-control" ng-model="$ctrl.model.Job.Image" id="schedule_image" name="schedule_image" placeholder="e.g. ubuntu:latest" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,12 +55,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !image-input -->
|
<!-- !image-input -->
|
||||||
|
<!-- retry-policy -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="col-sm-12 text-muted small">
|
<label for="retrycount" class="col-sm-2 control-label text-left">
|
||||||
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
|
Retry count
|
||||||
<code>/host</code> folder.
|
<portainer-tooltip position="bottom" message="Number of retries when it's not possible to reach the endpoint."></portainer-tooltip>
|
||||||
</span>
|
</label>
|
||||||
|
<div class="col-sm-10 col-md-4">
|
||||||
|
<input type="number" class="form-control" ng-model="$ctrl.model.Job.RetryCount" id="retrycount" name="retrycount" placeholder="3">
|
||||||
|
</div>
|
||||||
|
<label for="retryinterval" class="col-sm-2 control-label text-left">
|
||||||
|
Retry interval
|
||||||
|
<portainer-tooltip position="bottom" message="Retry interval in seconds."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10 col-md-4">
|
||||||
|
<input type="number" class="form-control" ng-model="$ctrl.model.Job.RetryInterval" id="retryinterval" name="retryinterval" placeholder="30">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- !retry-policy -->
|
||||||
<!-- execution-method -->
|
<!-- execution-method -->
|
||||||
<div ng-if="!$ctrl.model.Id">
|
<div ng-if="!$ctrl.model.Id">
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
|
@ -98,6 +110,12 @@
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Web editor
|
Web editor
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<code-editor
|
<code-editor
|
||||||
|
|
|
@ -29,6 +29,8 @@ function ScriptExecutionJobModel(data) {
|
||||||
this.Endpoints = data.Endpoints;
|
this.Endpoints = data.Endpoints;
|
||||||
this.FileContent = '';
|
this.FileContent = '';
|
||||||
this.Method = 'editor';
|
this.Method = 'editor';
|
||||||
|
this.RetryCount = data.RetryCount;
|
||||||
|
this.RetryInterval = data.RetryInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleCreateRequest(model) {
|
function ScheduleCreateRequest(model) {
|
||||||
|
@ -37,6 +39,8 @@ function ScheduleCreateRequest(model) {
|
||||||
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;
|
this.FileContent = model.Job.FileContent;
|
||||||
|
this.RetryCount = model.Job.RetryCount;
|
||||||
|
this.RetryInterval = model.Job.RetryInterval;
|
||||||
this.File = model.Job.File;
|
this.File = model.Job.File;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,4 +51,6 @@ function ScheduleUpdateRequest(model) {
|
||||||
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;
|
this.FileContent = model.Job.FileContent;
|
||||||
|
this.RetryCount = model.Job.RetryCount;
|
||||||
|
this.RetryInterval = model.Job.RetryInterval;
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,9 @@ angular.module('portainer.app')
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
CronExpression: payload.CronExpression,
|
CronExpression: payload.CronExpression,
|
||||||
Image: payload.Image,
|
Image: payload.Image,
|
||||||
Endpoints: Upload.json(payload.Endpoints)
|
Endpoints: Upload.json(payload.Endpoints),
|
||||||
|
RetryCount: payload.RetryCount,
|
||||||
|
RetryInterval: payload.RetryInterval
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue