feat(schedules): add the ability to list tasks from snapshots (#2458)

* feat(schedules): add the ability to list tasks from snapshots

* feat(schedules): update schedules

* refactor(schedules): fix linting issue
pull/2460/head
Anthony Lapenna 2018-11-13 14:39:26 +13:00 committed by GitHub
parent a2d9f591a7
commit 64c29f7402
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 440 additions and 149 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,7 +71,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))">
<td>
<a ui-sref="docker.containers.container.logs({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Id }}">
{{ item.Id | truncate: 32}}</a>
{{ item | containername }}</a>
</td>
<td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive"

View File

@ -0,0 +1,97 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.titleText }}
</div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Endpoint')">
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && $ctrl.state.reverseOrder"></i>
Endpoint
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Id')">
Id
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Status')">
State
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Created')">
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
Created
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))">
<td>
{{ item.Endpoint.Name }}
</td>
<td>
<a ng-click="$ctrl.goToContainerLogs(item.EndpointId, item.Id)">{{ item.Id | truncate: 32 }}</a>
</td>
<td>
<span class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
</td>
<td>
{{ item.Created | getisodatefromtimestamp}}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="9" class="text-center text-muted">No tasks available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -13,15 +13,53 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<schedule-form
ng-if="schedule"
model="schedule"
endpoints="endpoints"
groups="groups"
form-action="update"
form-action-label="Update schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
<uib-tabset active="state.activeTab">
<uib-tab index="0">
<uib-tab-heading>
<i class="fa fa-wrench" aria-hidden="true"></i> Configuration
</uib-tab-heading>
<schedule-form
ng-if="schedule"
model="schedule"
endpoints="endpoints"
groups="groups"
form-action="update"
form-action-label="Update schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
</uib-tab>
<uib-tab index="1">
<uib-tab-heading>
<i class="fa fa-tasks" aria-hidden="true"></i> Tasks
</uib-tab-heading>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Tasks are retrieved across all endpoints via snapshots. Data available in this view might not be up-to-date.
</span>
</div>
<div class="col-sm-12 form-section-title" style="margin-bottom: 20px;">
Tasks
</div>
<schedule-tasks-datatable
ng-if="tasks"
title-text="Tasks" title-icon="fa-tasks"
dataset="tasks"
table-key="schedule-tasks"
order-by="Status" reverse-order="true"
go-to-container-logs="goToContainerLogs"
></schedule-tasks-datatable>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>

View File

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