From a2d9f591a7108e205d625d0c55d449279823324f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 9 Nov 2018 15:22:08 +1300 Subject: [PATCH 01/17] feat(schedules): add retry policy to script schedules (#2445) --- api/cron/job_script_execution.go | 29 ++++- api/docker/job.go | 5 + api/errors.go | 5 + api/http/handler/schedules/schedule_create.go | 103 +++++++++++++----- api/http/handler/schedules/schedule_update.go | 12 ++ api/portainer.go | 10 +- .../forms/schedule-form/scheduleForm.html | 30 ++++- app/portainer/models/schedule.js | 6 + app/portainer/services/fileUpload.js | 4 +- 9 files changed, 160 insertions(+), 44 deletions(-) diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go index aafbc4892..b1b53d878 100644 --- a/api/cron/job_script_execution.go +++ b/api/cron/job_script_execution.go @@ -2,6 +2,7 @@ package cron import ( "log" + "time" "github.com/portainer/portainer" ) @@ -46,6 +47,7 @@ func (runner *ScriptExecutionJobRunner) Run() { return } + targets := make([]*portainer.Endpoint, 0) for _, endpointID := range runner.job.Endpoints { endpoint, err := runner.context.endpointService.Endpoint(endpointID) if err != nil { @@ -53,11 +55,32 @@ func (runner *ScriptExecutionJobRunner) Run() { return } - err = runner.context.jobService.Execute(endpoint, "", runner.job.Image, scriptFile) - if err != nil { - log.Printf("scheduled job error (script execution). Unable to execute scrtip (endpoint=%s) (err=%s)\n", endpoint.Name, err) + targets = append(targets, endpoint) + } + + 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 diff --git a/api/docker/job.go b/api/docker/job.go index eba82e3e5..ed721cd4b 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -41,6 +41,11 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image } defer cli.Close() + _, err = cli.Ping(context.Background()) + if err != nil { + return portainer.ErrUnableToPingEndpoint + } + err = pullImage(cli, image) if err != nil { return err diff --git a/api/errors.go b/api/errors.go index e348aaf48..da6c4edfe 100644 --- a/api/errors.go +++ b/api/errors.go @@ -88,6 +88,11 @@ const ( ErrUndefinedTLSFileType = Error("Undefined TLS file type") ) +// Docker errors. +const ( + ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint") +) + // Error represents an application error. type Error string diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index 625d707ca..03db5203e 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -14,23 +14,27 @@ import ( "github.com/portainer/portainer/cron" ) -type scheduleFromFilePayload struct { +type scheduleCreateFromFilePayload struct { Name string Image string CronExpression string Endpoints []portainer.EndpointID File []byte + RetryCount int + RetryInterval int } -type scheduleFromFileContentPayload struct { +type scheduleCreateFromFileContentPayload struct { Name string CronExpression string Image string Endpoints []portainer.EndpointID 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) if err != nil { return errors.New("Invalid name") @@ -62,10 +66,16 @@ func (payload *scheduleFromFilePayload) Validate(r *http.Request) error { } payload.File = file + retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true) + payload.RetryCount = retryCount + + retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true) + payload.RetryInterval = retryInterval + return nil } -func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error { +func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.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") } + if payload.RetryCount != 0 && payload.RetryInterval == 0 { + return portainer.Error("RetryInterval must be set") + } + 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 { - var payload scheduleFromFileContentPayload + var payload scheduleCreateFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { 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 { - 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) } func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - payload := &scheduleFromFilePayload{} + payload := &scheduleCreateFromFilePayload{} err := payload.Validate(r) if err != nil { 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 { - 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) } -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()) - scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file) - if err != nil { - return nil, err - } - job := &portainer.ScriptExecutionJob{ - Endpoints: endpoints, - Image: image, - ScriptPath: scriptPath, - ScheduleID: scheduleIdentifier, + Endpoints: payload.Endpoints, + Image: payload.Image, + ScheduleID: scheduleIdentifier, + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, } schedule := &portainer.Schedule{ ID: scheduleIdentifier, - Name: name, - CronExpression: cronExpression, + Name: payload.Name, + CronExpression: payload.CronExpression, JobType: portainer.ScriptExecutionJobType, ScriptExecutionJob: job, 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) - jobRunner := cron.NewScriptExecutionJobRunner(job, jobContext) + jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext) err = handler.JobScheduler.CreateSchedule(schedule, jobRunner) if err != nil { - return nil, err + return err } - err = handler.ScheduleService.CreateSchedule(schedule) - if err != nil { - return nil, err - } - - return schedule, nil + return handler.ScheduleService.CreateSchedule(schedule) } diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index e4de5b5f1..2c3b376f4 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -17,6 +17,8 @@ type scheduleUpdatePayload struct { CronExpression *string Endpoints []portainer.EndpointID FileContent *string + RetryCount *int + RetryInterval *int } func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { @@ -91,5 +93,15 @@ func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload 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 } diff --git a/api/portainer.go b/api/portainer.go index 9e3efb8e8..30ac5983d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -228,10 +228,12 @@ 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 + ScheduleID ScheduleID `json:"ScheduleId"` + Endpoints []EndpointID + Image string + ScriptPath string + RetryCount int + RetryInterval int } // SnapshotJob represents a scheduled job that can create endpoint snapshots diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html index 02ed2ebbe..4cdae3d96 100644 --- a/app/portainer/components/forms/schedule-form/scheduleForm.html +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -42,8 +42,8 @@
- -
+ +
@@ -55,12 +55,24 @@
+
- - This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the - /host folder. - + +
+ +
+ +
+ +
+
@@ -98,6 +110,12 @@
Web editor
+
+ + This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the + /host folder. + +
Date: Tue, 13 Nov 2018 14:39:26 +1300 Subject: [PATCH 02/17] 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 --- api/cmd/portainer/main.go | 12 +-- api/cron/job_endpoint_sync.go | 26 ++--- api/cron/job_script_execution.go | 36 +++---- api/cron/job_snapshot.go | 34 +++---- api/cron/scheduler.go | 69 +++++++++---- api/docker/job.go | 29 +++--- api/http/handler/endpoints/endpoint_job.go | 4 +- api/http/handler/schedules/handler.go | 2 + api/http/handler/schedules/schedule_create.go | 16 +-- api/http/handler/schedules/schedule_delete.go | 2 + api/http/handler/schedules/schedule_tasks.go | 87 +++++++++++++++++ api/http/handler/schedules/schedule_update.go | 4 +- api/http/handler/settings/settings_update.go | 7 +- api/portainer.go | 22 ++--- .../host-jobs-datatable/jobsDatatable.html | 2 +- .../scheduleTasksDatatable.html | 97 +++++++++++++++++++ .../scheduleTasksDatatable.js | 13 +++ app/portainer/models/schedule.js | 7 ++ app/portainer/rest/schedule.js | 3 +- app/portainer/services/api/scheduleService.js | 17 ++++ .../views/schedules/edit/schedule.html | 56 +++++++++-- .../schedules/edit/scheduleController.js | 44 +++++++-- 22 files changed, 440 insertions(+), 149 deletions(-) create mode 100644 api/http/handler/schedules/schedule_tasks.go create mode 100644 app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html create mode 100644 app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js 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'); From 381ab81fddc79caa12b50409fe670c4f2db3d7a0 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 15:18:38 +1300 Subject: [PATCH 03/17] fix(endpoints): ensure endpoint is up to date after snapshot (#2460) * feat(snapshots): fix a potential concurrency issue with endpoint snapshots * fix(endpoints): ensure endpoint is up to date after snapshot --- api/cron/job_snapshot.go | 23 ++++++++++++------ .../handler/endpoints/endpoint_snapshot.go | 24 +++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index 153d3c5b7..aaeff3212 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -42,6 +42,8 @@ func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { // 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. +// As a snapshot can be a long process, to avoid any concurrency issue we +// retrieve the latest version of the endpoint right after a snapshot. func (runner *SnapshotJobRunner) Run() { endpoints, err := runner.context.endpointService.Endpoints() if err != nil { @@ -54,18 +56,25 @@ func (runner *SnapshotJobRunner) Run() { continue } - snapshot, err := runner.context.snapshotter.CreateSnapshot(&endpoint) - endpoint.Status = portainer.EndpointStatusUp - if err != nil { - 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 + snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := runner.context.endpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown } if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} } - err = runner.context.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { 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/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 720f4c072..53e4c3339 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -3,6 +3,7 @@ package endpoints import ( "log" "net/http" + "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" @@ -21,18 +22,27 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) continue } - snapshot, err := handler.Snapshotter.CreateSnapshot(&endpoint) - endpoint.Status = portainer.EndpointStatusUp - if err != nil { - log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - endpoint.Status = portainer.EndpointStatusDown + time.Sleep(10 * time.Second) + + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown } if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} } - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } From cf370f6a4c6481313ec02477977770669294e4d5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 15:19:29 +1300 Subject: [PATCH 04/17] refactor(endpoints): remove time.Sleep call --- api/http/handler/endpoints/endpoint_snapshot.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 53e4c3339..0a457a383 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -3,7 +3,6 @@ package endpoints import ( "log" "net/http" - "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" @@ -22,8 +21,6 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) continue } - time.Sleep(10 * time.Second) - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) From 0825d0554675480c651670f402c8489809e23aaf Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 13 Nov 2018 16:02:49 +1300 Subject: [PATCH 05/17] feat(endpoints): improve offline banner UX (#2462) * feat(endpoints): add the last snapshot timestamp in offline banner * feat(endpoints): add the ability to refresh a snapshot in the offline banner --- .../handler/endpoints/endpoint_snapshot.go | 55 ++++++++++--------- .../handler/endpoints/endpoint_snapshots.go | 49 +++++++++++++++++ api/http/handler/endpoints/handler.go | 4 +- .../informationPanelOffline.html | 9 ++- .../informationPanelOffline.js | 2 +- .../informationPanelOfflineController.js | 34 ++++++++++++ app/portainer/rest/endpoint.js | 3 +- app/portainer/services/api/endpointService.js | 8 ++- app/portainer/views/home/homeController.js | 2 +- 9 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 api/http/handler/endpoints/endpoint_snapshots.go create mode 100644 app/portainer/components/information-panel-offline/informationPanelOfflineController.js diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 0a457a383..559cf127c 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -1,48 +1,51 @@ package endpoints import ( - "log" "net/http" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer" ) -// POST request on /api/endpoints/snapshot +// POST request on /api/endpoints/:id/snapshot func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - endpoints, err := handler.EndpointService.Endpoints() + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment { - continue - } + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + if endpoint.Type == portainer.AzureEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + } - latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) - if latestEndpointReference == nil { - log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - continue - } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) - latestEndpointReference.Status = portainer.EndpointStatusUp - if snapshotError != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) - latestEndpointReference.Status = portainer.EndpointStatusDown - } + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } - if snapshot != nil { - latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} - } + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + latestEndpointReference.Status = portainer.EndpointStatusDown + } - err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} - } + if snapshot != nil { + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} + } + + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go new file mode 100644 index 000000000..f59b7a40d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -0,0 +1,49 @@ +package endpoints + +import ( + "log" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// POST request on /api/endpoints/snapshot +func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.Type == portainer.AzureEnvironment { + continue + } + + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + + latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + if snapshot != nil { + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} + } + + err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 1ef8d1727..d8a94d360 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -45,7 +45,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo h.Handle("/endpoints", bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) h.Handle("/endpoints/snapshot", - bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", @@ -62,5 +62,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/job", bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/snapshot", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) return h } diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.html b/app/portainer/components/information-panel-offline/informationPanelOffline.html index a7c51a3e5..17942ee5a 100644 --- a/app/portainer/components/information-panel-offline/informationPanelOffline.html +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.html @@ -4,5 +4,12 @@ This endpoint is currently offline (read-only). Data shown is based on the latest available snapshot.

+

+ + Last snapshot: {{ $ctrl.snapshotTime | getisodatefromtimestamp }} +

+ - \ No newline at end of file + diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.js b/app/portainer/components/information-panel-offline/informationPanelOffline.js index c3f6e9517..e788dfe29 100644 --- a/app/portainer/components/information-panel-offline/informationPanelOffline.js +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.js @@ -1,4 +1,4 @@ angular.module('portainer.app').component('informationPanelOffline', { templateUrl: 'app/portainer/components/information-panel-offline/informationPanelOffline.html', - transclude: true + controller: 'InformationPanelOfflineController' }); diff --git a/app/portainer/components/information-panel-offline/informationPanelOfflineController.js b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js new file mode 100644 index 000000000..efe75545e --- /dev/null +++ b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app').controller('InformationPanelOfflineController', ['$state', 'EndpointProvider', 'EndpointService', 'Authentication', 'Notifications', +function StackDuplicationFormController($state, EndpointProvider, EndpointService, Authentication, Notifications) { + var ctrl = this; + + this.$onInit = onInit; + this.triggerSnapshot = triggerSnapshot; + + function triggerSnapshot() { + var endpointId = EndpointProvider.endpointID(); + + EndpointService.snapshotEndpoint(endpointId) + .then(function onSuccess() { + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); + }); + } + + function onInit() { + var endpointId = EndpointProvider.endpointID(); + ctrl.showRefreshButton = Authentication.getUserDetails().role === 1; + + + EndpointService.endpoint(endpointId) + .then(function onSuccess(data) { + ctrl.snapshotTime = data.Snapshots[0].Time; + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + } + +}]); diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 7b50372e9..068a8ef66 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -7,7 +7,8 @@ angular.module('portainer.app') update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - snapshot: { method: 'POST', params: { id: 'snapshot' }}, + snapshots: { method: 'POST', params: { action: 'snapshot' }}, + snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' }}, executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } } }); }]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index fa17052e5..f7098ae51 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -12,8 +12,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.query({}).$promise; }; - service.snapshot = function() { - return Endpoints.snapshot({}, {}).$promise; + service.snapshotEndpoints = function() { + return Endpoints.snapshots({}, {}).$promise; + }; + + service.snapshotEndpoint = function(endpointID) { + return Endpoints.snapshot({ id: endpointID }, {}).$promise; }; service.endpointsByGroup = function(groupId) { diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index 083c75625..b422a7e86 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -101,7 +101,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G } function triggerSnapshot() { - EndpointService.snapshot() + EndpointService.snapshotEndpoints() .then(function success() { Notifications.success('Success', 'Endpoints updated'); $state.reload(); From d455ab3fc74ffe287a2fca824209964de0a0c842 Mon Sep 17 00:00:00 2001 From: baron_l Date: Tue, 13 Nov 2018 04:08:12 +0100 Subject: [PATCH 06/17] feat(endpoints): enhance offline browsing (#2454) * feat(api): rewrite error response when trying to query a down endpoint * feat(interceptors): adding custom backend return code on offline fastfail --- api/http/handler/endpointproxy/proxy_docker.go | 5 +++++ app/docker/interceptors/containersInterceptor.js | 2 +- app/docker/interceptors/imagesInterceptor.js | 2 +- app/docker/interceptors/infoInterceptor.js | 2 +- app/docker/interceptors/networksInterceptor.js | 2 +- app/docker/interceptors/versionInterceptor.js | 2 +- app/docker/interceptors/volumesInterceptor.js | 2 +- app/portainer/interceptors/endpointStatusInterceptor.js | 2 +- 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index f03ca8e67..9ca932a97 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -1,6 +1,7 @@ package endpointproxy import ( + "errors" "strconv" httperror "github.com/portainer/libhttp/error" @@ -23,6 +24,10 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } + if endpoint.Status == portainer.EndpointStatusDown { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")} + } + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} diff --git a/app/docker/interceptors/containersInterceptor.js b/app/docker/interceptors/containersInterceptor.js index f717ea211..066ffe4fe 100644 --- a/app/docker/interceptors/containersInterceptor.js +++ b/app/docker/interceptors/containersInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Containers; diff --git a/app/docker/interceptors/imagesInterceptor.js b/app/docker/interceptors/imagesInterceptor.js index bdfcdd6de..9d79fbc86 100644 --- a/app/docker/interceptors/imagesInterceptor.js +++ b/app/docker/interceptors/imagesInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Images; diff --git a/app/docker/interceptors/infoInterceptor.js b/app/docker/interceptors/infoInterceptor.js index 716d4fbfd..17f310641 100644 --- a/app/docker/interceptors/infoInterceptor.js +++ b/app/docker/interceptors/infoInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Info; diff --git a/app/docker/interceptors/networksInterceptor.js b/app/docker/interceptors/networksInterceptor.js index 85bce4ba2..b5068534c 100644 --- a/app/docker/interceptors/networksInterceptor.js +++ b/app/docker/interceptors/networksInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Networks; diff --git a/app/docker/interceptors/versionInterceptor.js b/app/docker/interceptors/versionInterceptor.js index 4b6ed5776..95a5d56c4 100644 --- a/app/docker/interceptors/versionInterceptor.js +++ b/app/docker/interceptors/versionInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Version; diff --git a/app/docker/interceptors/volumesInterceptor.js b/app/docker/interceptors/volumesInterceptor.js index 268f6a4fd..a42addd2c 100644 --- a/app/docker/interceptors/volumesInterceptor.js +++ b/app/docker/interceptors/volumesInterceptor.js @@ -6,7 +6,7 @@ angular.module('portainer.app') interceptor.responseError = responseErrorInterceptor; function responseErrorInterceptor(rejection) { - if (rejection.status === 502 || rejection.status === -1) { + if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) { var endpoint = EndpointProvider.currentEndpoint(); if (endpoint !== undefined) { var data = endpoint.Snapshots[0].SnapshotRaw.Volumes; diff --git a/app/portainer/interceptors/endpointStatusInterceptor.js b/app/portainer/interceptors/endpointStatusInterceptor.js index 46ca7a42b..9411f8d62 100644 --- a/app/portainer/interceptors/endpointStatusInterceptor.js +++ b/app/portainer/interceptors/endpointStatusInterceptor.js @@ -30,7 +30,7 @@ angular.module('portainer.app') function responseErrorInterceptor(rejection) { var EndpointService = $injector.get('EndpointService'); var url = rejection.config.url; - if ((rejection.status === 502 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { + if ((rejection.status === 502 || rejection.status === 503 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { EndpointProvider.setOfflineMode(true); EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(true)}); } From 40e0c3879cd3c9fa54f9329e4ad253ea8e30034d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christer=20War=C3=A9n?= Date: Tue, 13 Nov 2018 23:01:36 +0200 Subject: [PATCH 07/17] style(dashboard): change blocklist-item border color (#2465) Changing blocklist-item border color to more confortable color that makes UI look more consistence --- assets/css/app.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 2be345ca2..0ab4b52cd 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -168,13 +168,13 @@ a[ng-click]{ padding: 0.7rem; margin-bottom: 0.7rem; cursor: pointer; - border: 1px solid #333333; + border: 1px solid #cccccc; border-radius: 2px; box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); } .blocklist-item--selected { - border: 2px solid #333333; + border: 2px solid #bbbbbb; background-color: #ececec; color: #2d3e63; } From 94d3d7bde252693e5c1f84c5339aef0bc3db48b6 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Nov 2018 12:20:33 +1300 Subject: [PATCH 08/17] feat(motd): relocate motd file URL and always return 200 (#2466) --- api/http/handler/motd/motd.go | 2 +- api/portainer.go | 2 +- app/portainer/views/home/home.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index ca279d890..53246fec1 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -18,7 +18,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { motd, err := client.Get(portainer.MessageOfTheDayURL, 0) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + response.JSON(w, &motdResponse{Message: ""}) return } diff --git a/api/portainer.go b/api/portainer.go index 6682017b7..f75574723 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -714,7 +714,7 @@ const ( // DBVersion is the version number of the Portainer database DBVersion = 14 // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved - MessageOfTheDayURL = "https://raw.githubusercontent.com/portainer/motd/master/message.html" + MessageOfTheDayURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/motd.html" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index 1ffaa88bd..86767459e 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -8,7 +8,7 @@ From 0ef25a4cbda2118b069505d0b1b0dfdab25e01bd Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Nov 2018 16:10:49 +1300 Subject: [PATCH 09/17] fix(schedules): add schedule name validation and remove endpoint name prefix (#2470) --- api/docker/job.go | 2 +- api/http/handler/schedules/schedule_create.go | 12 ++++++++++-- api/http/handler/schedules/schedule_update.go | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/docker/job.go b/api/docker/job.go index ea343e91c..7765f5f2e 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -83,7 +83,7 @@ func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, } if schedule != nil { - err = cli.ContainerRename(context.Background(), body.ID, endpoint.Name+"_"+schedule.Name+"_"+body.ID) + err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID) if err != nil { return err } diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go index d09f94860..4dca4c3f3 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -37,13 +37,17 @@ type scheduleCreateFromFileContentPayload struct { func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return errors.New("Invalid name") + return errors.New("Invalid schedule name") + } + + if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") } payload.Name = name image, err := request.RetrieveMultiPartFormValue(r, "Image", false) if err != nil { - return errors.New("Invalid image") + return errors.New("Invalid schedule image") } payload.Image = image @@ -80,6 +84,10 @@ func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) e return portainer.Error("Invalid schedule name") } + if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") + } + if govalidator.IsNull(payload.Image) { return portainer.Error("Invalid schedule image") } diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go index 12284181c..ed680dfa1 100644 --- a/api/http/handler/schedules/schedule_update.go +++ b/api/http/handler/schedules/schedule_update.go @@ -1,9 +1,11 @@ package schedules import ( + "errors" "net/http" "strconv" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -22,6 +24,9 @@ type scheduleUpdatePayload struct { } func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { + if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") + } return nil } From 488dc5f9db7feaf52c81a94545c16810dd64a362 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 16 Nov 2018 01:26:56 +0100 Subject: [PATCH 10/17] fix(network-creation): macvlan availability for standalone endpoints (#2441) --- .../views/networks/create/createNetworkController.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 9f5dd6493..a2893f353 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -105,7 +105,11 @@ angular.module('portainer.docker') config.ConfigFrom = { Network: selectedNetworkConfig.Name }; - config.Scope = 'swarm'; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + config.Scope = 'swarm'; + } else { + config.Scope = 'local'; + } } function validateForm(accessControlData, isAdmin) { @@ -192,9 +196,6 @@ angular.module('portainer.docker') PluginService.networkPlugins(apiVersion < 1.25) .then(function success(data) { - if ($scope.applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE') { - data.splice(data.indexOf('macvlan'), 1); - } $scope.availableNetworkDrivers = data; }) .catch(function error(err) { From fe8dfee69a611256193576b94ff896f2905a3e50 Mon Sep 17 00:00:00 2001 From: baron_l Date: Mon, 19 Nov 2018 07:07:38 +0100 Subject: [PATCH 11/17] feat(home): display each endpoint URL (#2471) --- .../components/endpoint-list/endpoint-item/endpointItem.html | 3 +++ .../components/endpoint-list/endpoint-list-controller.js | 1 + app/portainer/components/endpoint-list/endpointList.html | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index 6e643da5b..f9c720cf6 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -82,6 +82,9 @@ + + {{ $ctrl.model.URL | stripprotocol }} +
diff --git a/app/portainer/components/endpoint-list/endpoint-list-controller.js b/app/portainer/components/endpoint-list/endpoint-list-controller.js index aab801410..bffbacc1d 100644 --- a/app/portainer/components/endpoint-list/endpoint-list-controller.js +++ b/app/portainer/components/endpoint-list/endpoint-list-controller.js @@ -44,6 +44,7 @@ angular.module('portainer.app').controller('EndpointListController', [ return ( _.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) || _.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) || + _.includes(endpoint.URL.toLowerCase(), lowerCaseKeyword) || _.some(endpoint.Tags, function(tag) { return _.includes(tag.toLowerCase(), lowerCaseKeyword); }) || diff --git a/app/portainer/components/endpoint-list/endpointList.html b/app/portainer/components/endpoint-list/endpointList.html index 2886916f1..9deecf8ca 100644 --- a/app/portainer/components/endpoint-list/endpointList.html +++ b/app/portainer/components/endpoint-list/endpointList.html @@ -21,7 +21,7 @@ class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onFilterChanged()" - placeholder="Search by name, group, tag, status..." auto-focus> + placeholder="Search by name, group, tag, status, URL..." auto-focus>
From d03fd5805a56949992f6d9af5de5c69a9952cec5 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 23 Nov 2018 11:46:51 +1300 Subject: [PATCH 12/17] feat(api): support AGENT_SECRET environment variable (#2486) --- api/cmd/portainer/main.go | 3 ++- api/crypto/ecdsa.go | 23 +++++++++++++++++--- api/docker/client.go | 2 +- api/exec/swarm_stack.go | 2 +- api/http/handler/websocket/websocket_exec.go | 3 ++- api/http/proxy/docker_transport.go | 2 +- api/portainer.go | 2 +- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0e0c189ac..f7890c2c4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -2,6 +2,7 @@ package main // import "github.com/portainer/portainer" import ( "encoding/json" + "os" "strings" "time" @@ -88,7 +89,7 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService { } func initDigitalSignatureService() portainer.DigitalSignatureService { - return &crypto.ECDSAService{} + return crypto.NewECDSAService(os.Getenv("AGENT_SECRET")) } func initCryptoService() portainer.CryptoService { diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 003547531..f2b87d639 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -8,6 +8,8 @@ import ( "encoding/base64" "encoding/hex" "math/big" + + "github.com/portainer/portainer" ) const ( @@ -26,6 +28,15 @@ type ECDSAService struct { privateKey *ecdsa.PrivateKey publicKey *ecdsa.PublicKey encodedPubKey string + secret string +} + +// NewECDSAService returns a pointer to a ECDSAService. +// An optional secret can be specified +func NewECDSAService(secret string) *ECDSAService { + return &ECDSAService{ + secret: secret, + } } // EncodedPublicKey returns the encoded version of the public that can be used @@ -91,11 +102,17 @@ func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) { return private, public, nil } -// Sign creates a signature from a message. -// It automatically hash the message using MD5 and creates a signature from +// CreateSignature creates a digital signature. +// It automatically hash a specific message using MD5 and creates a signature from // that hash. // It then encodes the generated signature in base64. -func (service *ECDSAService) Sign(message string) (string, error) { +func (service *ECDSAService) CreateSignature() (string, error) { + + message := portainer.PortainerAgentSignatureMessage + if service.secret != "" { + message = service.secret + } + hash := HashFromBytes([]byte(message)) r := big.NewInt(0) diff --git a/api/docker/client.go b/api/docker/client.go index 9c608a19b..913e5dd87 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -67,7 +67,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer. return nil, err } - signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := signatureService.CreateSignature() if err != nil { return nil, err } diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index aa32bfe54..f41a581db 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -140,7 +140,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) return err } - signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := manager.signatureService.CreateSignature() if err != nil { return err } diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index d797e020a..6f2d07d22 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -111,12 +111,13 @@ func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Req } } - signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := handler.SignatureService.CreateSignature() if err != nil { return err } proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) out.Set(portainer.PortainerAgentSignatureHeader, signature) out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 37e46c2dd..2476685cc 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -64,7 +64,7 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon request.URL.Path = path if p.enableSignature { - signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := p.SignatureService.CreateSignature() if err != nil { return nil, err } diff --git a/api/portainer.go b/api/portainer.go index f75574723..ee2d6687f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -626,7 +626,7 @@ type ( GenerateKeyPair() ([]byte, []byte, error) EncodedPublicKey() string PEMHeaders() (string, string) - Sign(message string) (string, error) + CreateSignature() (string, error) } // JWTService represents a service for managing JWT tokens From 5e49f934b9176577de30bd66df9a4c43e86cf797 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 23 Nov 2018 09:44:34 +0100 Subject: [PATCH 13/17] fix(containers-stats): accessing a down container stats wont display a js error anymore (#2484) --- app/docker/models/container.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index a28e978cb..6666f1e03 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -71,8 +71,12 @@ function ContainerStatsViewModel(data) { this.NumProcs = data.num_procs; this.isWindows = true; } else { // Linux - this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; - this.MemoryCache = data.memory_stats.stats.cache; + if (data.memory_stats.stats === undefined || data.memory_stats.usage === undefined) { + this.MemoryUsage = this.MemoryCache = 0; + } else { + this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache; + this.MemoryCache = data.memory_stats.stats.cache; + } } this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; From 17d63ae3caf4344cf32a8124ad189f37f5dca60b Mon Sep 17 00:00:00 2001 From: Olli Janatuinen Date: Fri, 23 Nov 2018 11:00:30 +0200 Subject: [PATCH 14/17] chore(dependencies): updated xterm to 3.8.0 version (#2452) --- package.json | 3 +-- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6d7486278..71aa19cc9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "clean-all": "yarn grunt clean:all", "build": "yarn grunt build", "build-offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -b portainer ../../../dist/portainer-linux-amd64" - }, "engines": { "node": ">= 0.8.4" @@ -65,7 +64,7 @@ "splitargs": "github:deviantony/splitargs#~0.2.0", "toastr": "github:CodeSeven/toastr#~2.1.3", "ui-select": "^0.19.8", - "xterm": "^3.1.0" + "xterm": "^3.8.0" }, "devDependencies": { "autoprefixer": "^7.1.1", diff --git a/yarn.lock b/yarn.lock index 275854f70..7592562cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4506,9 +4506,10 @@ xtend@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a" -xterm@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.1.0.tgz#7f7e1c8cf4b80bd881a4e8891213b851423e90c9" +xterm@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.8.0.tgz#55d1de518bdc9c9793823f5e4e97d6898972938d" + integrity sha512-rS3HLryuMWbLsv98+jVVSUXCxmoyXPwqwJNC0ad0VSMdXgl65LefPztQVwfurkaF7kM7ZSgM8eJjnJ9kkdoR1w== yargs@~3.10.0: version "3.10.0" From d510bbbcfd470651cb62256dc547211f5b73b6fc Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sat, 24 Nov 2018 08:40:56 +1300 Subject: [PATCH 15/17] feat(api): filter LDAP password from settings response (#2488) --- api/http/handler/settings/handler.go | 4 ++++ api/http/handler/settings/settings_inspect.go | 1 + api/portainer.go | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index d20c388d7..0acbd2ca6 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -9,6 +9,10 @@ import ( "github.com/portainer/portainer/http/security" ) +func hideFields(settings *portainer.Settings) { + settings.LDAPSettings.Password = "" +} + // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index c922e1f47..a28b1ef09 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -14,5 +14,6 @@ func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } + hideFields(settings) return response.JSON(w, settings) } diff --git a/api/portainer.go b/api/portainer.go index ee2d6687f..634d8635c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -47,7 +47,7 @@ type ( // LDAPSettings represents the settings used to connect to a LDAP server LDAPSettings struct { ReaderDN string `json:"ReaderDN"` - Password string `json:"Password"` + Password string `json:"Password,omitempty"` URL string `json:"URL"` TLSConfig TLSConfiguration `json:"TLSConfig"` StartTLS bool `json:"StartTLS"` From 52788029ede8de40c2313bffefc2839c3a7198f9 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 23 Nov 2018 23:11:58 +0100 Subject: [PATCH 16/17] feat(container-details): add visual feedback when creating image from container (#2487) --- app/docker/views/containers/edit/containerController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index f687b116c..63902e77e 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -154,6 +154,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function () { update(); Notifications.success('Container commited', $transition$.params().id); + $scope.config.Image = ''; }, function (e) { update(); Notifications.error('Failure', e, 'Unable to commit container'); From b809177147f29ba987164479fa962832ac91e24f Mon Sep 17 00:00:00 2001 From: Andreas Roussos Date: Sat, 24 Nov 2018 22:46:13 +0200 Subject: [PATCH 17/17] feat(dashboard): use plural form only when required * fix(endpoint-item): use plural form only when required * refactor(endpoint-item): use clearer patterns * refactor(dashboard): use clearer patterns --- app/docker/views/dashboard/dashboard.html | 12 ++++++------ .../endpoint-list/endpoint-item/endpointItem.html | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 9d2751010..89cee5867 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -84,7 +84,7 @@
{{ stackCount }}
-
Stacks
+
{{ stackCount === 1 ? 'Stack' : 'Stacks' }}
@@ -97,7 +97,7 @@
{{ serviceCount }}
-
Services
+
{{ serviceCount === 1 ? 'Service' : 'Services' }}
@@ -114,7 +114,7 @@
{{ containers | stoppedcontainers }} stopped
{{ containers.length }}
-
Containers
+
{{ containers.length === 1 ? 'Container' : 'Containers' }}
@@ -130,7 +130,7 @@
{{ images | imagestotalsize | humansize }}
{{ images.length }}
-
Images
+
{{ images.length === 1 ? 'Image' : 'Images' }}
@@ -143,7 +143,7 @@
{{ volumeCount }}
-
Volumes
+
{{ volumeCount === 1 ? 'Volume' : 'Volumes' }}
@@ -156,7 +156,7 @@
{{ networkCount }}
-
Networks
+
{{ networkCount === 1 ? 'Network' : 'Networks' }}
diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index f9c720cf6..a2f96a392 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -38,13 +38,13 @@ - {{ $ctrl.model.Snapshots[0].StackCount }} stacks + {{ $ctrl.model.Snapshots[0].StackCount }} {{ $ctrl.model.Snapshots[0].StackCount === 1 ? 'stack' : 'stacks' }} - {{ $ctrl.model.Snapshots[0].ServiceCount }} services + {{ $ctrl.model.Snapshots[0].ServiceCount }} {{ $ctrl.model.Snapshots[0].ServiceCount === 1 ? 'service' : 'services' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} containers + {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount === 1 ? 'container' : 'containers' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount }} @@ -52,10 +52,10 @@ - {{ $ctrl.model.Snapshots[0].VolumeCount }} volumes + {{ $ctrl.model.Snapshots[0].VolumeCount }} {{ $ctrl.model.Snapshots[0].VolumeCount === 1 ? 'volume' : 'volumes' }} - {{ $ctrl.model.Snapshots[0].ImageCount }} images + {{ $ctrl.model.Snapshots[0].ImageCount }} {{ $ctrl.model.Snapshots[0].ImageCount === 1 ? 'image' : 'images' }}