diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 2fffc2a69..0e0c189ac 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -141,9 +141,9 @@ func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter } snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter) - snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotJob, snapshotJobContext) + snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext) - err = jobScheduler.CreateSchedule(snapshotSchedule, snapshotJobRunner) + err = jobScheduler.ScheduleJob(snapshotJobRunner) if err != nil { return err } @@ -179,9 +179,9 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul } endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints) - endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncJob, endpointSyncJobContext) + endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endointSyncSchedule, endpointSyncJobContext) - err = jobScheduler.CreateSchedule(endointSyncSchedule, endpointSyncJobRunner) + err = jobScheduler.ScheduleJob(endpointSyncJobRunner) if err != nil { return err } @@ -199,9 +199,9 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p if schedule.JobType == portainer.ScriptExecutionJobType { jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext) - err = jobScheduler.CreateSchedule(&schedule, jobRunner) + err = jobScheduler.ScheduleJob(jobRunner) if err != nil { return err } diff --git a/api/cron/job_endpoint_sync.go b/api/cron/job_endpoint_sync.go index d6ff92173..ef13a4970 100644 --- a/api/cron/job_endpoint_sync.go +++ b/api/cron/job_endpoint_sync.go @@ -11,8 +11,8 @@ import ( // EndpointSyncJobRunner is used to run a EndpointSyncJob type EndpointSyncJobRunner struct { - job *portainer.EndpointSyncJob - context *EndpointSyncJobContext + schedule *portainer.Schedule + context *EndpointSyncJobContext } // EndpointSyncJobContext represents the context of execution of a EndpointSyncJob @@ -30,10 +30,10 @@ func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpoi } // NewEndpointSyncJobRunner returns a new runner that can be scheduled -func NewEndpointSyncJobRunner(job *portainer.EndpointSyncJob, context *EndpointSyncJobContext) *EndpointSyncJobRunner { +func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner { return &EndpointSyncJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } @@ -53,19 +53,9 @@ type fileEndpoint struct { TLSKey string `json:"TLSKey,omitempty"` } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *EndpointSyncJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID -} - -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *EndpointSyncJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *EndpointSyncJobRunner) GetJobType() portainer.JobType { - return portainer.EndpointSyncJobType +// GetSchedule returns the schedule associated to the runner +func (runner *EndpointSyncJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } // Run triggers the execution of the endpoint synchronization process. diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go index b1b53d878..6f984e8fd 100644 --- a/api/cron/job_script_execution.go +++ b/api/cron/job_script_execution.go @@ -9,8 +9,8 @@ import ( // ScriptExecutionJobRunner is used to run a ScriptExecutionJob type ScriptExecutionJobRunner struct { - job *portainer.ScriptExecutionJob - context *ScriptExecutionJobContext + schedule *portainer.Schedule + context *ScriptExecutionJobContext } // ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob @@ -30,10 +30,10 @@ func NewScriptExecutionJobContext(jobService portainer.JobService, endpointServi } // NewScriptExecutionJobRunner returns a new runner that can be scheduled -func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { +func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { return &ScriptExecutionJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } @@ -41,14 +41,14 @@ func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *Scr // It will iterate through all the endpoints specified in the context to // execute the script associated to the job. func (runner *ScriptExecutionJobRunner) Run() { - scriptFile, err := runner.context.fileService.GetFileContent(runner.job.ScriptPath) + scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath) if err != nil { log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err) return } targets := make([]*portainer.Endpoint, 0) - for _, endpointID := range runner.job.Endpoints { + for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints { endpoint, err := runner.context.endpointService.Endpoint(endpointID) if err != nil { log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err) @@ -65,7 +65,7 @@ func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.E retryTargets := make([]*portainer.Endpoint, 0) for _, endpoint := range endpoints { - err := runner.context.jobService.Execute(endpoint, "", runner.job.Image, script) + err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule) if err == portainer.ErrUnableToPingEndpoint { retryTargets = append(retryTargets, endpoint) } else if err != nil { @@ -74,26 +74,16 @@ func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.E } retryCount++ - if retryCount >= runner.job.RetryCount { + if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount { return } - time.Sleep(time.Duration(runner.job.RetryInterval) * time.Second) + time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second) runner.executeAndRetry(retryTargets, script, retryCount) } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *ScriptExecutionJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID -} - -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *ScriptExecutionJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *ScriptExecutionJobRunner) GetJobType() portainer.JobType { - return portainer.ScriptExecutionJobType +// GetSchedule returns the schedule associated to the runner +func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index 5918b47aa..153d3c5b7 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -8,8 +8,8 @@ import ( // SnapshotJobRunner is used to run a SnapshotJob type SnapshotJobRunner struct { - job *portainer.SnapshotJob - context *SnapshotJobContext + schedule *portainer.Schedule + context *SnapshotJobContext } // SnapshotJobContext represents the context of execution of a SnapshotJob @@ -27,35 +27,25 @@ func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotte } // NewSnapshotJobRunner returns a new runner that can be scheduled -func NewSnapshotJobRunner(job *portainer.SnapshotJob, context *SnapshotJobContext) *SnapshotJobRunner { +func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner { return &SnapshotJobRunner{ - job: job, - context: context, + schedule: schedule, + context: context, } } -// GetScheduleID returns the schedule identifier associated to the runner -func (runner *SnapshotJobRunner) GetScheduleID() portainer.ScheduleID { - return runner.job.ScheduleID +// GetSchedule returns the schedule associated to the runner +func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule } -// SetScheduleID sets the schedule identifier associated to the runner -func (runner *SnapshotJobRunner) SetScheduleID(ID portainer.ScheduleID) { - runner.job.ScheduleID = ID -} - -// GetJobType returns the job type associated to the runner -func (runner *SnapshotJobRunner) GetJobType() portainer.JobType { - return portainer.EndpointSyncJobType -} - -// Run triggers the execution of the job. +// Run triggers the execution of the schedule. // It will iterate through all the endpoints available in the database to // create a snapshot of each one of them. func (runner *SnapshotJobRunner) Run() { endpoints, err := runner.context.endpointService.Endpoints() if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) + log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) return } @@ -67,7 +57,7 @@ func (runner *SnapshotJobRunner) Run() { snapshot, err := runner.context.snapshotter.CreateSnapshot(&endpoint) endpoint.Status = portainer.EndpointStatusUp if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) endpoint.Status = portainer.EndpointStatusDown } @@ -77,7 +67,7 @@ func (runner *SnapshotJobRunner) Run() { err = runner.context.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { - log.Printf("background job error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) return } } diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go index f329d00bd..2cff329e8 100644 --- a/api/cron/scheduler.go +++ b/api/cron/scheduler.go @@ -17,31 +17,25 @@ func NewJobScheduler() *JobScheduler { } } -// CreateSchedule schedules the execution of a job via a runner -func (scheduler *JobScheduler) CreateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error { - runner.SetScheduleID(schedule.ID) - return scheduler.cron.AddJob(schedule.CronExpression, runner) +// ScheduleJob schedules the execution of a job via a runner +func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error { + return scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner) } -// UpdateSchedule updates a specific scheduled job by re-creating a new cron +// UpdateSystemJobSchedule updates the first occurence of the specified +// scheduled job based on the specified job type. +// It does so by re-creating a new cron // and adding all the existing jobs. It will then re-schedule the new job -// via the specified JobRunner parameter. +// with the update cron expression passed in parameter. // NOTE: the cron library do not support updating schedules directly // hence the work-around -func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error { +func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error { cronEntries := scheduler.cron.Entries() newCron := cron.New() for _, entry := range cronEntries { - - if entry.Job.(portainer.JobRunner).GetScheduleID() == schedule.ID { - - var jobRunner cron.Job = runner - if entry.Job.(portainer.JobRunner).GetJobType() == portainer.SnapshotJobType { - jobRunner = entry.Job - } - - err := newCron.AddJob(schedule.CronExpression, jobRunner) + if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType { + err := newCron.AddJob(newCronExpression, entry.Job) if err != nil { return err } @@ -56,17 +50,50 @@ func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runn return nil } -// RemoveSchedule remove a scheduled job by re-creating a new cron -// and adding all the existing jobs except for the one specified via scheduleID. -// NOTE: the cron library do not support removing schedules directly +// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron +// and adding all the existing jobs. It will then re-schedule the new job +// via the specified JobRunner parameter. +// NOTE: the cron library do not support updating schedules directly // hence the work-around -func (scheduler *JobScheduler) RemoveSchedule(scheduleID portainer.ScheduleID) { +func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error { cronEntries := scheduler.cron.Entries() newCron := cron.New() for _, entry := range cronEntries { - if entry.Job.(portainer.JobRunner).GetScheduleID() == scheduleID { + if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID { + + var jobRunner cron.Job = runner + if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType { + jobRunner = entry.Job + } + + err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner) + if err != nil { + return err + } + } + + newCron.Schedule(entry.Schedule, entry.Job) + } + + scheduler.cron.Stop() + scheduler.cron = newCron + scheduler.cron.Start() + return nil +} + +// UnscheduleJob remove a scheduled job by re-creating a new cron +// and adding all the existing jobs except for the one specified via scheduleID. +// NOTE: the cron library do not support removing schedules directly +// hence the work-around +func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) { + cronEntries := scheduler.cron.Entries() + newCron := cron.New() + + for _, entry := range cronEntries { + + if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID { continue } diff --git a/api/docker/job.go b/api/docker/job.go index ed721cd4b..ea343e91c 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -18,24 +18,25 @@ import ( // JobService represents a service that handles the execution of jobs type JobService struct { - DockerClientFactory *ClientFactory + dockerClientFactory *ClientFactory } // NewJobService returns a pointer to a new job service func NewJobService(dockerClientFactory *ClientFactory) *JobService { return &JobService{ - DockerClientFactory: dockerClientFactory, + dockerClientFactory: dockerClientFactory, } } -// Execute will execute a script on the endpoint host with the supplied image as a container -func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image string, script []byte) error { +// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename. +// It will copy the script content specified as a parameter inside a container based on the specified image and execute it. +func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error { buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) if err != nil { return err } - cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName) + cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName) if err != nil { return err } @@ -64,6 +65,10 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}), } + if schedule != nil { + containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID)) + } + hostConfig := &container.HostConfig{ Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"}, NetworkMode: "host", @@ -77,6 +82,13 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image return err } + if schedule != nil { + err = cli.ContainerRename(context.Background(), body.ID, endpoint.Name+"_"+schedule.Name+"_"+body.ID) + if err != nil { + return err + } + } + copyOptions := types.CopyToContainerOptions{} err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions) if err != nil { @@ -84,12 +96,7 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image } startOptions := types.ContainerStartOptions{} - err = cli.ContainerStart(context.Background(), body.ID, startOptions) - if err != nil { - return err - } - - return nil + return cli.ContainerStart(context.Background(), body.ID, startOptions) } func pullImage(cli *client.Client, image string) error { diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go index 025f6ab3a..565418f42 100644 --- a/api/http/handler/endpoints/endpoint_job.go +++ b/api/http/handler/endpoints/endpoint_job.go @@ -92,7 +92,7 @@ func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File) + err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } @@ -107,7 +107,7 @@ func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent)) + err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} } diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go index 073c05606..4c5b64bbd 100644 --- a/api/http/handler/schedules/handler.go +++ b/api/http/handler/schedules/handler.go @@ -37,5 +37,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) h.Handle("/schedules/{id}/file", bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) + h.Handle("/schedules/{id}/tasks", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index 03db5203e..d09f94860 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -158,9 +158,9 @@ func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCre scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + // ScheduleID: scheduleIdentifier, RetryCount: payload.RetryCount, RetryInterval: payload.RetryInterval, } @@ -181,9 +181,9 @@ func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *sche scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + // ScheduleID: scheduleIdentifier, RetryCount: payload.RetryCount, RetryInterval: payload.RetryInterval, } @@ -209,9 +209,9 @@ func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file schedule.ScriptExecutionJob.ScriptPath = scriptPath jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) - err = handler.JobScheduler.CreateSchedule(schedule, jobRunner) + err = handler.JobScheduler.ScheduleJob(jobRunner) if err != nil { return err } diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go index 51c01eec9..7597fb6c0 100644 --- a/api/http/handler/schedules/schedule_delete.go +++ b/api/http/handler/schedules/schedule_delete.go @@ -34,6 +34,8 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} } + handler.JobScheduler.UnscheduleJob(schedule.ID) + err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err} diff --git a/api/http/handler/schedules/schedule_tasks.go b/api/http/handler/schedules/schedule_tasks.go new file mode 100644 index 000000000..da88fdac7 --- /dev/null +++ b/api/http/handler/schedules/schedule_tasks.go @@ -0,0 +1,87 @@ +package schedules + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type taskContainer struct { + ID string `json:"Id"` + EndpointID portainer.EndpointID `json:"EndpointId"` + Status string `json:"Status"` + Created float64 `json:"Created"` + Labels map[string]string `json:"Labels"` +} + +// GET request on /api/schedules/:id/tasks +func (handler *Handler) scheduleTasks(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 schedule tasks", errors.New("This type of schedule do not have any associated tasks")} + } + + tasks := make([]taskContainer, 0) + + for _, endpointID := range schedule.ScriptExecutionJob.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrObjectNotFound { + continue + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err} + } + + tasks = append(tasks, endpointTasks...) + } + + return response.JSON(w, tasks) +} + +func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) { + endpointTasks := make([]taskContainer, 0) + if len(endpoint.Snapshots) == 0 { + return endpointTasks, nil + } + + b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers) + if err != nil { + return nil, err + } + + var containers []taskContainer + err = json.Unmarshal(b, &containers) + if err != nil { + return nil, err + } + + for _, container := range containers { + if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) { + container.EndpointID = endpoint.ID + endpointTasks = append(endpointTasks, container) + } + } + + return endpointTasks, nil +} diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 2c3b376f4..12284181c 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -56,8 +56,8 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) * if updateJobSchedule { jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) - err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner) + jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) + err := handler.JobScheduler.UpdateJobSchedule(jobRunner) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err} } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 472a34ada..bef9cee9e 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -108,7 +108,12 @@ func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, sna snapshotSchedule := schedules[0] snapshotSchedule.CronExpression = "@every " + snapshotInterval - err := handler.JobScheduler.UpdateSchedule(&snapshotSchedule, nil) + err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression) + if err != nil { + return err + } + + err = handler.ScheduleService.UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule) if err != nil { return err } diff --git a/api/portainer.go b/api/portainer.go index 30ac5983d..6682017b7 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -228,7 +228,6 @@ type ( // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container ScriptExecutionJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` Endpoints []EndpointID Image string ScriptPath string @@ -237,14 +236,10 @@ type ( } // SnapshotJob represents a scheduled job that can create endpoint snapshots - SnapshotJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` - } + SnapshotJob struct{} // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file - EndpointSyncJob struct { - ScheduleID ScheduleID `json:"ScheduleId"` - } + EndpointSyncJob struct{} // Schedule represents a scheduled job. // It only contains a pointer to one of the JobRunner implementations @@ -668,18 +663,17 @@ type ( // JobScheduler represents a service to run jobs on a periodic basis JobScheduler interface { - CreateSchedule(schedule *Schedule, runner JobRunner) error - UpdateSchedule(schedule *Schedule, runner JobRunner) error - RemoveSchedule(ID ScheduleID) + ScheduleJob(runner JobRunner) error + UpdateJobSchedule(runner JobRunner) error + UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error + UnscheduleJob(ID ScheduleID) Start() } // JobRunner represents a service that can be used to run a job JobRunner interface { Run() - GetScheduleID() ScheduleID - SetScheduleID(ID ScheduleID) - GetJobType() JobType + GetSchedule() *Schedule } // Snapshotter represents a service used to create endpoint snapshots @@ -710,7 +704,7 @@ type ( // JobService represents a service to manage job execution on hosts JobService interface { - Execute(endpoint *Endpoint, nodeName, image string, script []byte) error + ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error } ) diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html index 38f58e6a9..e3fe91b87 100644 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -71,7 +71,7 @@ - {{ item.Id | truncate: 32}} + {{ item | containername }} +
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Endpoint + + + + Id + + + + + + State + + + + + + + + Created + +
+ {{ item.Endpoint.Name }} + + {{ item.Id | truncate: 32 }} + + {{ item.Status }} + + {{ item.Created | getisodatefromtimestamp}} +
Loading...
No tasks available.
+
+ +
+
+
+
+ diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js new file mode 100644 index 000000000..cdd7344c6 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.docker').component('scheduleTasksDatatable', { + templateUrl: 'app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + goToContainerLogs: '<' + } +}); diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js index f48fd42bc..874ebc8d5 100644 --- a/app/portainer/models/schedule.js +++ b/app/portainer/models/schedule.js @@ -33,6 +33,13 @@ function ScriptExecutionJobModel(data) { this.RetryInterval = data.RetryInterval; } +function ScriptExecutionTaskModel(data) { + this.Id = data.Id; + this.EndpointId = data.EndpointId; + this.Status = createStatus(data.Status); + this.Created = data.Created; +} + function ScheduleCreateRequest(model) { this.Name = model.Name; this.CronExpression = model.CronExpression; diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js index df57ef68e..eae5a218f 100644 --- a/app/portainer/rest/schedule.js +++ b/app/portainer/rest/schedule.js @@ -8,6 +8,7 @@ function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - file: { method: 'GET', params: { id : '@id', action: 'file' } } + file: { method: 'GET', params: { id : '@id', action: 'file' } }, + tasks: { method: 'GET', isArray: true, params: { id : '@id', action: 'tasks' } } }); }]); diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js index e1698b9c1..3f5d81de2 100644 --- a/app/portainer/services/api/scheduleService.js +++ b/app/portainer/services/api/scheduleService.js @@ -36,6 +36,23 @@ function ScheduleService($q, Schedules, FileUploadService) { return deferred.promise; }; + service.scriptExecutionTasks = function(scheduleId) { + var deferred = $q.defer(); + + Schedules.tasks({ id: scheduleId }).$promise + .then(function success(data) { + var tasks = data.map(function (item) { + return new ScriptExecutionTaskModel(item); + }); + deferred.resolve(tasks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tasks associated to the schedule', err: err }); + }); + + return deferred.promise; + }; + service.createScheduleFromFileContent = function(model) { var payload = new ScheduleCreateRequest(model); return Schedules.create({ method: 'string' }, payload).$promise; diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html index 654ac8d50..fb7aa5625 100644 --- a/app/portainer/views/schedules/edit/schedule.html +++ b/app/portainer/views/schedules/edit/schedule.html @@ -13,15 +13,53 @@
- + + + + + Configuration + + + + + + + + + Tasks + + +
+ Information +
+
+ + Tasks are retrieved across all endpoints via snapshots. Data available in this view might not be up-to-date. + +
+ +
+ Tasks +
+ +
+ +
diff --git a/app/portainer/views/schedules/edit/scheduleController.js b/app/portainer/views/schedules/edit/scheduleController.js index ce05d528d..06313f3ab 100644 --- a/app/portainer/views/schedules/edit/scheduleController.js +++ b/app/portainer/views/schedules/edit/scheduleController.js @@ -1,12 +1,13 @@ angular.module('portainer.app') -.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', -function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService) { +.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', 'EndpointProvider', +function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService, EndpointProvider) { $scope.state = { actionInProgress: false }; $scope.update = update; + $scope.goToContainerLogs = goToContainerLogs; function update() { var model = $scope.schedule; @@ -25,25 +26,48 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou }); } + function goToContainerLogs(endpointId, containerId) { + EndpointProvider.setEndpointID(endpointId); + $state.go('docker.containers.container.logs', { id: containerId }); + } + + function associateEndpointsToTasks(tasks, endpoints) { + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + + for (var j = 0; j < endpoints.length; j++) { + var endpoint = endpoints[j]; + + if (task.EndpointId === endpoint.Id) { + task.Endpoint = endpoint; + break; + } + } + } + } + function initView() { var id = $transition$.params().id; - var schedule = null; $q.all({ schedule: ScheduleService.schedule(id), + file: ScheduleService.getScriptFile(id), + tasks: ScheduleService.scriptExecutionTasks(id), endpoints: EndpointService.endpoints(), groups: GroupService.groups() }) .then(function success(data) { - schedule = data.schedule; + var schedule = data.schedule; + schedule.Job.FileContent = data.file.ScheduleFileContent; + + var endpoints = data.endpoints; + var tasks = data.tasks; + associateEndpointsToTasks(tasks, endpoints); + + $scope.schedule = schedule; + $scope.tasks = data.tasks; $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');