1
0
Fork 0
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:
Anthony Lapenna 2018-11-09 15:22:08 +13:00 committed by GitHub
parent e7ab057c81
commit a2d9f591a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 160 additions and 44 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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