diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 2fffc2a69..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 { @@ -141,9 +142,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 +180,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 +200,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 aafbc4892..6f984e8fd 100644 --- a/api/cron/job_script_execution.go +++ b/api/cron/job_script_execution.go @@ -2,14 +2,15 @@ package cron import ( "log" + "time" "github.com/portainer/portainer" ) // 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 @@ -29,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, } } @@ -40,37 +41,49 @@ 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 } - for _, endpointID := range runner.job.Endpoints { + targets := make([]*portainer.Endpoint, 0) + 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) 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.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule) + 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.schedule.ScriptExecutionJob.RetryCount { + return + } + + 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..aaeff3212 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,27 @@ 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. +// 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 { - 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 } @@ -64,20 +56,27 @@ func (runner *SnapshotJobRunner) Run() { continue } - 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) - 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 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/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/docker/job.go b/api/docker/job.go index eba82e3e5..7765f5f2e 100644 --- a/api/docker/job.go +++ b/api/docker/job.go @@ -18,29 +18,35 @@ 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 } defer cli.Close() + _, err = cli.Ping(context.Background()) + if err != nil { + return portainer.ErrUnableToPingEndpoint + } + err = pullImage(cli, image) if err != nil { return err @@ -59,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", @@ -72,6 +82,13 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image return err } + if schedule != nil { + err = cli.ContainerRename(context.Background(), body.ID, 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 { @@ -79,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/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/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/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/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/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 720f4c072..559cf127c 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -1,41 +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, 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 - } + if endpoint.Type == portainer.AzureEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + } - if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} - } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} - } + 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} + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + 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/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/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/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 625d707ca..4dca4c3f3 100644 --- a/api/http/handler/schedules/schedule_create.go +++ b/api/http/handler/schedules/schedule_create.go @@ -14,32 +14,40 @@ 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") + 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 @@ -62,14 +70,24 @@ 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") } + 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") } @@ -86,6 +104,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 +129,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(), } - jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(job, jobContext) - - err = handler.JobScheduler.CreateSchedule(schedule, jobRunner) - if err != nil { - return nil, err - } - - err = handler.ScheduleService.CreateSchedule(schedule) - if err != nil { - return nil, err - } - - return schedule, nil + 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(schedule, jobContext) + + err = handler.JobScheduler.ScheduleJob(jobRunner) + if err != nil { + return err + } + + return handler.ScheduleService.CreateSchedule(schedule) } 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 e4de5b5f1..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" @@ -17,9 +19,14 @@ type scheduleUpdatePayload struct { CronExpression *string Endpoints []portainer.EndpointID FileContent *string + RetryCount *int + RetryInterval *int } 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 } @@ -54,8 +61,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} } @@ -91,5 +98,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/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/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/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 9e3efb8e8..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"` @@ -228,21 +228,18 @@ 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 + Endpoints []EndpointID + Image string + ScriptPath string + RetryCount int + RetryInterval int } // 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 @@ -629,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 @@ -666,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 @@ -708,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 } ) @@ -718,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/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 }} -1) { @@ -74,8 +74,12 @@ export 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; diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 8a2c93046..7a9aff9c9 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -156,6 +156,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'); 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/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 347e32dff..cd1e66a1b 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -108,7 +108,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) { @@ -195,9 +199,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) { diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html new file mode 100644 index 000000000..818b978a5 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html @@ -0,0 +1,97 @@ +
+
+
+ + +
+
+ + {{ $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..422b3ff05 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.docker').component('scheduleTasksDatatable', { + templateUrl: './scheduleTasksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + goToContainerLogs: '<' + } +}); diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index 6e643da5b..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' }} @@ -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 37ad827d8..b83427e8f 100644 --- a/app/portainer/components/endpoint-list/endpoint-list-controller.js +++ b/app/portainer/components/endpoint-list/endpoint-list-controller.js @@ -46,6 +46,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>
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. + +