diff --git a/README.md b/README.md index 7b2e9e096..cee536856 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable) -[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer) +[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer-ci/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer-ci) [![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) [![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) diff --git a/api/archive/tar.go b/api/archive/tar.go index 4040a9ec7..3beccec8a 100644 --- a/api/archive/tar.go +++ b/api/archive/tar.go @@ -7,13 +7,13 @@ import ( // TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content // specified in fileContent. Returns the archive as a byte array. -func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) { +func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, error) { var buffer bytes.Buffer tarWriter := tar.NewWriter(&buffer) header := &tar.Header{ Name: fileName, - Mode: 0600, + Mode: mode, Size: int64(len(fileContent)), } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index cecf986b7..7602b2a90 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -383,6 +383,10 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter) } +func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService { + return docker.NewJobService(dockerClientFactory) +} + func main() { flags := initCLI() @@ -408,6 +412,8 @@ func main() { clientFactory := initClientFactory(digitalSignatureService) + jobService := initJobService(clientFactory) + snapshotter := initSnapshotter(clientFactory) jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags) @@ -520,6 +526,7 @@ func main() { SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, DockerClientFactory: clientFactory, + JobService: jobService, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/docker/client.go b/api/docker/client.go index af9f08c46..9c608a19b 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -3,7 +3,6 @@ package docker import ( "net/http" "strings" - "time" "github.com/docker/docker/client" "github.com/portainer/portainer" @@ -27,12 +26,13 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService) *Clien } // CreateClient is a generic function to create a Docker client based on -// a specific endpoint configuration -func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) { +// a specific endpoint configuration. The nodeName parameter can be used +// with an agent enabled endpoint to target a specific node in an agent cluster. +func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { if endpoint.Type == portainer.AzureEnvironment { return nil, unsupportedEnvironmentType } else if endpoint.Type == portainer.AgentOnDockerEnvironment { - return createAgentClient(endpoint, factory.signatureService) + return createAgentClient(endpoint, factory.signatureService, nodeName) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { @@ -61,7 +61,7 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { ) } -func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) { +func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) { httpCli, err := httpClient(endpoint) if err != nil { return nil, err @@ -77,6 +77,10 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer. portainer.PortainerAgentSignatureHeader: signature, } + if nodeName != "" { + headers[portainer.PortainerAgentTargetHeader] = nodeName + } + return client.NewClientWithOpts( client.WithHost(endpoint.URL), client.WithVersion(portainer.SupportedDockerAPIVersion), @@ -97,7 +101,6 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { } return &http.Client{ - Timeout: time.Second * 10, Transport: transport, }, nil } diff --git a/api/docker/jobservice.go b/api/docker/jobservice.go new file mode 100644 index 000000000..d2559d7d2 --- /dev/null +++ b/api/docker/jobservice.go @@ -0,0 +1,103 @@ +package docker + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "strconv" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/strslice" + "github.com/docker/docker/client" + "github.com/portainer/portainer" + "github.com/portainer/portainer/archive" +) + +// JobService represnts a service that handles jobs on the host +type JobService struct { + DockerClientFactory *ClientFactory +} + +// NewJobService returns a pointer to a new job service +func NewJobService(dockerClientFactory *ClientFactory) *JobService { + return &JobService{ + 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 { + buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) + if err != nil { + return err + } + + cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName) + if err != nil { + return err + } + defer cli.Close() + + err = pullImage(cli, image) + if err != nil { + return err + } + + containerConfig := &container.Config{ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + WorkingDir: "/tmp", + Image: image, + Labels: map[string]string{ + "io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)), + }, + Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}), + } + + hostConfig := &container.HostConfig{ + Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"}, + NetworkMode: "host", + Privileged: true, + } + + networkConfig := &network.NetworkingConfig{} + + body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "") + if err != nil { + return err + } + + copyOptions := types.CopyToContainerOptions{} + err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions) + if err != nil { + return err + } + + startOptions := types.ContainerStartOptions{} + err = cli.ContainerStart(context.Background(), body.ID, startOptions) + if err != nil { + return err + } + + return nil +} + +func pullImage(cli *client.Client, image string) error { + imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{}) + if err != nil { + return err + } + defer imageReadCloser.Close() + + _, err = io.Copy(ioutil.Discard, imageReadCloser) + if err != nil { + return err + } + + return nil +} diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go index 34cb35def..ee095f6d5 100644 --- a/api/docker/snapshotter.go +++ b/api/docker/snapshotter.go @@ -18,7 +18,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { // CreateSnapshot creates a snapshot of a specific endpoint func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { - cli, err := snapshotter.clientFactory.CreateClient(endpoint) + cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") if err != nil { return nil, err } diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go new file mode 100644 index 000000000..025f6ab3a --- /dev/null +++ b/api/http/handler/endpoints/endpoint_job.go @@ -0,0 +1,116 @@ +package endpoints + +import ( + "errors" + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type endpointJobFromFilePayload struct { + Image string + File []byte +} + +type endpointJobFromFileContentPayload struct { + Image string + FileContent string +} + +func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error { + file, _, err := request.RetrieveMultiPartFormFile(r, "File") + if err != nil { + return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly") + } + payload.File = file + + image, err := request.RetrieveMultiPartFormValue(r, "Image", false) + if err != nil { + return portainer.Error("Invalid image name") + } + payload.Image = image + + return nil +} + +func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.FileContent) { + return portainer.Error("Invalid script file content") + } + + if govalidator.IsNull(payload.Image) { + return portainer.Error("Invalid image name") + } + + return nil +} + +// POST request on /api/endpoints/:id/job?method&nodeName +func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true) + + 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} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + switch method { + case "file": + return handler.executeJobFromFile(w, r, endpoint, nodeName) + case "string": + return handler.executeJobFromFileContent(w, r, endpoint, nodeName) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)} +} + +func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { + payload := &endpointJobFromFilePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + } + + return response.Empty(w) +} + +func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { + var payload endpointJobFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 779cd9390..1ef8d1727 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -31,6 +31,7 @@ type Handler struct { FileService portainer.FileService ProxyManager *proxy.Manager Snapshotter portainer.Snapshotter + JobService portainer.JobService } // NewHandler creates a handler to manage endpoint operations. @@ -59,6 +60,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) - + h.Handle("/endpoints/{id}/job", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index f43045899..9126942e7 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -49,7 +49,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *portainer.Endpoint, resourceID string) *httperror.HandlerError { - dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint) + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err} } diff --git a/api/http/proxy/build.go b/api/http/proxy/build.go index 0deab93b9..aaa486f07 100644 --- a/api/http/proxy/build.go +++ b/api/http/proxy/build.go @@ -43,7 +43,7 @@ func buildOperation(request *http.Request) error { dockerfileContent = []byte(req.Content) } - buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile") + buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile", 0600) if err != nil { return err } diff --git a/api/http/server.go b/api/http/server.go index 2258e86d3..bd5b9fe30 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -68,6 +68,7 @@ type Server struct { SSLCert string SSLKey string DockerClientFactory *docker.ClientFactory + JobService portainer.JobService } // Start starts the HTTP server @@ -109,6 +110,7 @@ func (server *Server) Start() error { endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager endpointHandler.Snapshotter = server.Snapshotter + endpointHandler.JobService = server.JobService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) endpointGroupHandler.EndpointGroupService = server.EndpointGroupService diff --git a/api/portainer.go b/api/portainer.go index 2db839506..c1cdbab4f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -635,6 +635,11 @@ type ( Up(stack *Stack, endpoint *Endpoint) error Down(stack *Stack, endpoint *Endpoint) error } + + // JobService represents a service to manage job execution on hosts + JobService interface { + Execute(endpoint *Endpoint, nodeName, image string, script []byte) error + } ) const ( diff --git a/api/swagger.yaml b/api/swagger.yaml index 5a6e7ff87..51d8a9f68 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -153,6 +153,8 @@ paths: operationId: "DockerHubInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -175,6 +177,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -211,6 +215,8 @@ paths: operationId: "EndpointList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -233,6 +239,8 @@ paths: - "multipart/form-data" produces: - "application/json" + security: + - jwt: [] parameters: - name: "Name" in: "formData" @@ -265,7 +273,7 @@ paths: - name: "TLSSkipVerify" in: "formData" type: "string" - description: "Skip server verification when using TLS" (example: false) + description: "Skip server verification when using TLS (example: false)" - name: "TLSCACertFile" in: "formData" type: "file" @@ -324,6 +332,8 @@ paths: operationId: "EndpointInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -365,6 +375,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -413,6 +425,8 @@ paths: Remove an endpoint. **Access policy**: administrator operationId: "EndpointDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -460,6 +474,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -495,6 +511,78 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /endpoints/{id}/job: + post: + tags: + - "endpoints" + summary: "Execute a job on the endpoint host" + description: | + Execute a job (script) on the underlying host of the endpoint. + **Access policy**: administrator + operationId: "EndpointJob" + consumes: + - "application/json" + produces: + - "application/json" + security: + - jwt: [] + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "method" + in: "query" + description: "Job execution method. Possible values: file or string." + required: true + type: "string" + - name: "nodeName" + in: "query" + description: "Optional. Hostname of a node when targeting a Portainer agent cluster." + required: true + type: "string" + - in: "body" + name: "body" + description: "Job details. Required when method equals string." + required: true + schema: + $ref: "#/definitions/EndpointJobRequest" + - name: "Image" + in: "formData" + type: "string" + description: "Container image which will be used to execute the job. Required when method equals file." + - name: "file" + in: "formData" + type: "file" + description: "Job script file. Required when method equals file." + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Endpoint" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /endpoint_groups: get: tags: @@ -508,6 +596,8 @@ paths: operationId: "EndpointGroupList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -530,6 +620,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -564,6 +656,8 @@ paths: operationId: "EndpointGroupInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -605,6 +699,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -655,6 +751,8 @@ paths: Remove an endpoint group. **Access policy**: administrator operationId: "EndpointGroupDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -702,6 +800,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -750,6 +850,8 @@ paths: operationId: "RegistryList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -772,6 +874,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -813,6 +917,8 @@ paths: operationId: "RegistryInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -854,6 +960,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -904,6 +1012,8 @@ paths: Remove a registry. **Access policy**: administrator operationId: "RegistryDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -944,6 +1054,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -992,6 +1104,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1042,6 +1156,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1092,6 +1208,8 @@ paths: Remove a resource control. **Access policy**: restricted operationId: "ResourceControlDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1137,6 +1255,8 @@ paths: operationId: "SettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1159,6 +1279,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1193,6 +1315,8 @@ paths: operationId: "PublicSettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1216,6 +1340,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1248,6 +1374,8 @@ paths: operationId: "StatusInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1271,6 +1399,8 @@ paths: operationId: "StackList" produces: - "application/json" + security: + - jwt: [] parameters: - name: "filters" in: "query" @@ -1303,6 +1433,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "type" in: "query" @@ -1382,6 +1514,8 @@ paths: operationId: "StackInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1427,6 +1561,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1479,6 +1615,8 @@ paths: Remove a stack. **Access policy**: restricted operationId: "StackDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1529,6 +1667,8 @@ paths: operationId: "StackFileInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1574,6 +1714,8 @@ paths: operationId: "StackMigrate" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1628,6 +1770,8 @@ paths: operationId: "UserList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1651,6 +1795,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1699,6 +1845,8 @@ paths: operationId: "UserInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1740,6 +1888,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1790,6 +1940,8 @@ paths: Remove a user. **Access policy**: administrator operationId: "UserDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1828,6 +1980,8 @@ paths: operationId: "UserMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1871,6 +2025,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1918,6 +2074,8 @@ paths: operationId: "UserAdminCheck" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 204: @@ -1947,6 +2105,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1991,6 +2151,8 @@ paths: - multipart/form-data produces: - "application/json" + security: + - jwt: [] parameters: - in: "path" name: "certificate" @@ -2032,6 +2194,8 @@ paths: operationId: "TagList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2054,6 +2218,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2093,6 +2259,8 @@ paths: Remove a tag. **Access policy**: administrator operationId: "TagDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2125,6 +2293,8 @@ paths: operationId: "TeamList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2147,6 +2317,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2195,6 +2367,8 @@ paths: operationId: "TeamInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2243,6 +2417,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2284,6 +2460,8 @@ paths: Remove a team. **Access policy**: administrator operationId: "TeamDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2323,6 +2501,8 @@ paths: operationId: "TeamMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2364,6 +2544,8 @@ paths: operationId: "TeamMembershipList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2393,6 +2575,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2443,6 +2627,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2493,6 +2679,8 @@ paths: Remove a team membership. Access is only available to administrators leaders of the associated team. **Access policy**: restricted operationId: "TeamMembershipDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2539,6 +2727,8 @@ paths: operationId: "TemplateList" produces: - "application/json" + security: + - jwt: [] parameters: responses: 200: @@ -2561,6 +2751,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2602,6 +2794,8 @@ paths: operationId: "TemplateInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2650,6 +2844,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2698,6 +2894,8 @@ paths: Remove a template. **Access policy**: administrator operationId: "TemplateDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -3783,9 +3981,9 @@ definitions: TemplateCreateRequest: type: "object" required: - - "type" - - "title" - - "description" + - "type" + - "title" + - "description" properties: type: type: "integer" @@ -4137,7 +4335,7 @@ definitions: TemplateRepository: type: "object" required: - - "URL" + - "URL" properties: URL: type: "string" @@ -4164,6 +4362,20 @@ definitions: type: "string" example: "new-stack" description: "If provided will rename the migrated stack" + EndpointJobRequest: + type: "object" + required: + - "Image" + - "FileContent" + properties: + Image: + type: "string" + example: "ubuntu:latest" + description: "Container image which will be used to execute the job" + FileContent: + type: "string" + example: "ls -lah /host/tmp" + description: "Content of the job script" StackCreateRequest: type: "object" required: diff --git a/api/swagger_config.json b/api/swagger_config.json new file mode 100644 index 000000000..957f1358e --- /dev/null +++ b/api/swagger_config.json @@ -0,0 +1,5 @@ +{ + "packageName": "portainer", + "packageVersion": "1.20-dev", + "projectName": "portainer" +} diff --git a/app/agent/components/volume-browser/volume-browser.js b/app/agent/components/volume-browser/volume-browser.js index b14782318..bea11e61a 100644 --- a/app/agent/components/volume-browser/volume-browser.js +++ b/app/agent/components/volume-browser/volume-browser.js @@ -3,6 +3,7 @@ angular.module('portainer.agent').component('volumeBrowser', { controller: 'VolumeBrowserController', bindings: { volumeId: '<', - nodeName: '<' + nodeName: '<', + isUploadEnabled: '<' } }); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 3759f22d2..97c8a4da6 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -8,4 +8,7 @@ rename="$ctrl.rename(name, newName)" download="$ctrl.download(name)" delete="$ctrl.delete(name)" + + is-upload-allowed="$ctrl.isUploadEnabled" + on-file-selected-for-upload="$ctrl.onFileSelectedForUpload" > diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js index c4c0e9d02..312a9517f 100644 --- a/app/agent/components/volume-browser/volumeBrowserController.js +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -87,6 +87,16 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService }); } + this.onFileSelectedForUpload = function onFileSelectedForUpload(file) { + VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId) + .then(function onFileUpload() { + onFileUploaded(); + }) + .catch(function onFileUpload(err) { + Notifications.error('Failure', err, 'Unable to upload file'); + }); + }; + function parentPath(path) { if (path.lastIndexOf('/') === 0) { return '/'; @@ -115,4 +125,14 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService }); }; + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + browse(ctrl.state.path); + } + + + }]); diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index a7800717c..522296c3a 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -1,8 +1,10 @@ angular.module('portainer.agent') -.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { - endpointId: EndpointProvider.endpointID + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { query: { method: 'GET', isArray: true } diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index 166e1da59..0537e7819 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -2,10 +2,12 @@ import angular from 'angular'; import { browseGetResponse } from './response/browse'; angular.module('portainer.agent') -.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', { - endpointId: EndpointProvider.endpointID + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { ls: { diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js index a717def30..f184d2544 100644 --- a/app/agent/rest/host.js +++ b/app/agent/rest/host.js @@ -1,11 +1,12 @@ angular.module('portainer.agent').factory('Host', [ - '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', - function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action', + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action', { - endpointId: EndpointProvider.endpointID + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { info: { method: 'GET', params: { action: 'info' } } diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js new file mode 100644 index 000000000..7eeb93f2e --- /dev/null +++ b/app/agent/rest/ping.js @@ -0,0 +1,33 @@ +angular.module('portainer.agent').factory('AgentPing', [ + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', + function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/ping', + { + endpointId: EndpointProvider.endpointID + }, + { + ping: { + method: 'GET', + interceptor: { + response: function versionInterceptor(response) { + var instance = response.resource; + var version = + response.headers('Portainer-Agent-Api-Version') || 1; + instance.version = Number(version); + return instance; + }, + responseError: function versionResponseError(error) { + // 404 - agent is up - set version to 1 + if (error.status === 404) { + return { version: 1 }; + } + return $q.reject(error); + } + } + } + } + ); + } +]); diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js new file mode 100644 index 000000000..a78755b35 --- /dev/null +++ b/app/agent/rest/v1/agent.js @@ -0,0 +1,10 @@ +angular.module('portainer.agent') +.factory('AgentVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { + endpointId: EndpointProvider.endpointID + }, + { + query: { method: 'GET', isArray: true } + }); +}]); diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js new file mode 100644 index 000000000..5ad405401 --- /dev/null +++ b/app/agent/rest/v1/browse.js @@ -0,0 +1,25 @@ +import angular from 'angular'; +import { browseGetResponse } from '../response/browse'; + +angular.module('portainer.agent') +.factory('BrowseVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', { + endpointId: EndpointProvider.endpointID + }, + { + ls: { + method: 'GET', isArray: true, params: { action: 'ls' } + }, + get: { + method: 'GET', params: { action: 'get' }, + transformResponse: browseGetResponse + }, + delete: { + method: 'DELETE', params: { action: 'delete' } + }, + rename: { + method: 'PUT', params: { action: 'rename' } + } + }); +}]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index 9df212227..c89a3e23d 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -2,14 +2,19 @@ import angular from 'angular'; import { AgentViewModel } from '../models/agent'; angular.module('portainer.agent').factory('AgentService', [ - '$q', 'Agent','HttpRequestHelper', 'Host', - function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) { + '$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager', + function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { 'use strict'; var service = {}; service.agents = agents; service.hostInfo = hostInfo; + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + function hostInfo(nodeName) { HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); return Host.info().$promise; @@ -18,7 +23,10 @@ angular.module('portainer.agent').factory('AgentService', [ function agents() { var deferred = $q.defer(); - Agent.query({}) + var agentVersion = getAgentApiVersion(); + var service = agentVersion > 1 ? Agent : AgentVersion1; + + service.query({ version: agentVersion }) .$promise.then(function success(data) { var agents = data.map(function(item) { return new AgentViewModel(item); diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js index 935d34be4..6f292c36a 100644 --- a/app/agent/services/hostBrowserService.js +++ b/app/agent/services/hostBrowserService.js @@ -1,6 +1,6 @@ angular.module('portainer.agent').factory('HostBrowserService', [ - 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', - function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager', + function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) { var service = {}; service.ls = ls; @@ -31,9 +31,17 @@ angular.module('portainer.agent').factory('HostBrowserService', [ function upload(path, file, onProgress) { var deferred = $q.defer(); - var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put'; + var agentVersion = StateManager.getAgentApiVersion(); + var url = + API_ENDPOINT_ENDPOINTS + + '/' + + EndpointProvider.endpointID() + + '/docker' + + (agentVersion > 1 ? '/v' + agentVersion : '') + + '/browse/put'; + Upload.upload({ - url: url, + url: url, data: { file: file, Path: path } }).then(deferred.resolve, deferred.reject, onProgress); return deferred.promise; diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js new file mode 100644 index 000000000..765d47a5f --- /dev/null +++ b/app/agent/services/pingService.js @@ -0,0 +1,14 @@ +angular.module('portainer.agent').service('AgentPingService', [ + 'AgentPing', + function AgentPingService(AgentPing) { + var service = {}; + + service.ping = ping; + + function ping() { + return AgentPing.ping().$promise; + } + + return service; + } +]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 1da020c38..a4ffdbf9d 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,27 +1,60 @@ angular.module('portainer.agent').factory('VolumeBrowserService', [ - '$q', 'Browse', - function VolumeBrowserServiceFactory($q, Browse) { + 'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload', + function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { 'use strict'; var service = {}; + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + + function getBrowseService() { + var agentVersion = getAgentApiVersion(); + return agentVersion > 1 ? Browse : BrowseVersion1; + } + service.ls = function(volumeId, path) { - return Browse.ls({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.get = function(volumeId, path) { - return Browse.get({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.delete = function(volumeId, path) { - return Browse.delete({ volumeID: volumeId, path: path }).$promise; + return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; service.rename = function(volumeId, path, newPath) { var payload = { - CurrentFilePath: path, + CurrentFilePath: path, NewFilePath: newPath }; - return Browse.rename({ volumeID: volumeId }, payload).$promise; + return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; + }; + + service.upload = function upload(path, file, volumeId, onProgress) { + var deferred = $q.defer(); + var agentVersion = StateManager.getAgentApiVersion(); + if (agentVersion <2) { + deferred.reject('upload is not supported on this agent version'); + return; + } + var url = + API_ENDPOINT_ENDPOINTS + + '/' + + EndpointProvider.endpointID() + + '/docker' + + '/v' + agentVersion + + '/browse/put?volumeID=' + + volumeId; + + Upload.upload({ + url: url, + data: { file: file, Path: path } + }).then(deferred.resolve, deferred.reject, onProgress); + return deferred.promise; }; return service; diff --git a/app/app.js b/app/app.js index a32633fe2..3c54bc3bf 100644 --- a/app/app.js +++ b/app/app.js @@ -37,9 +37,13 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); - authManager.redirectWhenUnauthenticated(); Authentication.init(); - $rootScope.$on('tokenHasExpired', function() { + + // The unauthenticated event is broadcasted by the jwtInterceptor when + // hitting a 401. We're using this instead of the usual combination of + // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector + // to have more controls on which URL should trigger the unauthenticated state. + $rootScope.$on('unauthenticated', function () { $state.go('portainer.auth', {error: 'Your session has expired'}); }); } diff --git a/app/config.js b/app/config.js index db74d1230..196c8c491 100644 --- a/app/config.js +++ b/app/config.js @@ -19,9 +19,6 @@ angular.module('portainer') jwtOptionsProvider.config({ tokenGetter: ['LocalStorage', function(LocalStorage) { return LocalStorage.getJWT(); - }], - unauthenticatedRedirector: ['$state', function($state) { - $state.go('portainer.auth', {error: 'Your session has expired'}); }] }); $httpProvider.interceptors.push('jwtInterceptor'); diff --git a/app/docker/__module.js b/app/docker/__module.js index 1e616c5c8..abe5fcab1 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -151,6 +151,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var hostJob = { + name: 'docker.host.job', + url: '/job', + views: { + 'content@': { + component: 'hostJobView' + } + } + }; + var events = { name: 'docker.events', url: '/events', @@ -265,6 +275,16 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var nodeJob = { + name: 'docker.nodes.node.job', + url: '/job', + views: { + 'content@': { + component: 'nodeJobView' + } + } + }; + var secrets = { name: 'docker.secrets', url: '/secrets', @@ -436,7 +456,7 @@ angular.module('portainer.docker', ['portainer.app']) } }; - + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); @@ -452,6 +472,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(host); $stateRegistryProvider.register(hostBrowser); + $stateRegistryProvider.register(hostJob); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); @@ -463,6 +484,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(nodes); $stateRegistryProvider.register(node); $stateRegistryProvider.register(nodeBrowser); + $stateRegistryProvider.register(nodeJob); $stateRegistryProvider.register(secrets); $stateRegistryProvider.register(secret); $stateRegistryProvider.register(secretCreation); diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html new file mode 100644 index 000000000..38f58e6a9 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -0,0 +1,118 @@ +
+
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Id + + + + + + State + + + +
+ Filter + + Filter + +
+ +
+ + + + Created + +
+ + {{ item.Id | truncate: 32}} + + {{ item.Status }} + + {{ item.Status }} + + {{item.Created | getisodatefromtimestamp}} +
Loading...
No jobs available.
+
+ +
+
+
+
+
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js new file mode 100644 index 000000000..741e25148 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('jobsDatatable', { + templateUrl: './jobsDatatable.html', + controller: 'JobsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<' + } +}); \ No newline at end of file diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js new file mode 100644 index 000000000..8a920a3c5 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -0,0 +1,143 @@ +import angular from 'angular'; +import _ from 'lodash'; + +angular.module('portainer.docker') + .controller('JobsDatatableController', ['$q', '$state', 'PaginationService', 'DatatableService', 'ContainerService', 'ModalService', 'Notifications', + function ($q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) { + var ctrl = this; + + this.state = { + orderBy: this.orderBy, + paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), + displayTextFilter: false + }; + + this.filters = { + state: { + open: false, + enabled: false, + values: [] + } + }; + + this.changeOrderBy = function (orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); + }; + + this.changePaginationLimit = function () { + PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + }; + + this.applyFilters = function (value) { + var container = value; + var filters = ctrl.filters; + for (var i = 0; i < filters.state.values.length; i++) { + var filter = filters.state.values[i]; + if (container.Status === filter.label && filter.display) { + return true; + } + } + return false; + }; + + this.onStateFilterChange = function () { + var filters = this.filters.state.values; + var filtered = false; + for (var i = 0; i < filters.length; i++) { + var filter = filters[i]; + if (!filter.display) { + filtered = true; + } + } + this.filters.state.enabled = filtered; + DatatableService.setDataTableFilters(this.tableKey, this.filters); + }; + + this.prepareTableFromDataset = function () { + var availableStateFilters = []; + for (var i = 0; i < this.dataset.length; i++) { + var item = this.dataset[i]; + availableStateFilters.push({ + label: item.Status, + display: true + }); + } + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + }; + + this.updateStoredFilters = function (storedFilters) { + var datasetFilters = this.filters.state.values; + + for (var i = 0; i < datasetFilters.length; i++) { + var filter = datasetFilters[i]; + var existingFilter = _.find(storedFilters, ['label', filter.label]); + if (existingFilter && !existingFilter.display) { + filter.display = existingFilter.display; + this.filters.state.enabled = true; + } + } + }; + + function confirmPurgeJobs() { + return showConfirmationModal(); + + function showConfirmationModal() { + var deferred = $q.defer(); + + ModalService.confirm({ + title: 'Are you sure ?', + message: 'Clearing job history will remove all stopped jobs containers.', + buttons: { + confirm: { + label: 'Purge', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + deferred.resolve(confirmed); + } + }); + + return deferred.promise; + } + } + + this.purgeAction = function () { + confirmPurgeJobs().then(function success(confirmed) { + if (!confirmed) { + return $q.when(); + } + ContainerService.prune({ label: ['io.portainer.job.endpoint'] }).then(function success() { + Notifications.success('Success', 'Job hisotry cleared'); + $state.reload(); + }).catch(function error(err) { + Notifications.error('Failure', err.message, 'Unable to clear job history'); + }); + }); + }; + + this.$onInit = function () { + setDefaults(this); + this.prepareTableFromDataset(); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.updateStoredFilters(storedFilters.state.values); + } + this.filters.state.open = false; + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } + } + ]); diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html index afcc4313f..0db396cea 100644 --- a/app/docker/components/host-overview/host-overview.html +++ b/app/docker/components/host-overview/host-overview.html @@ -11,14 +11,25 @@ Docker - + - - + - \ No newline at end of file + + + + diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js index da098ef6d..a0421d4b5 100644 --- a/app/docker/components/host-overview/host-overview.js +++ b/app/docker/components/host-overview/host-overview.js @@ -6,8 +6,12 @@ angular.module('portainer.docker').component('hostOverview', { devices: '<', disks: '<', isAgent: '<', + agentApiVersion: '<', refreshUrl: '@', - browseUrl: '@' + browseUrl: '@', + jobUrl: '@', + isJobEnabled: '<', + jobs: '<' }, transclude: true }); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html index 9bb9b7f85..9b5b006d7 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -26,20 +26,19 @@ Total memory {{ $ctrl.host.totalMemory | humansize }} - + - + - - \ No newline at end of file + diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js index 328832c42..5d25a626e 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -1,9 +1,10 @@ angular.module('portainer.docker').component('hostDetailsPanel', { - templateUrl: - 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', + templateUrl: './host-details-panel.html', bindings: { host: '<', - isAgent: '<', - browseUrl: '@' + isJobEnabled: '<', + isBrowseEnabled: '<', + browseUrl: '@', + jobUrl: '@' } }); diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 5d1cfa4db..ae1d42dd3 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -67,7 +67,8 @@ export function ContainerViewModel(data) { export function ContainerStatsViewModel(data) { this.Date = data.read; - this.MemoryUsage = data.memory_stats.usage; + 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; this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index 81fbe8cb0..51f68499e 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -71,6 +71,9 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { }, update: { method: 'POST', params: { id: '@id', action: 'update'} + }, + prune: { + method: 'POST', params: { action: 'prune', filters: '@filters' } } }); }]); diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index 51d5b4113..1a6940302 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -189,5 +189,9 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe return Container.inspect({ id: id }).$promise; }; + service.prune = function(filters) { + return Container.prune({ filters: filters }).$promise; + }; + return service; }]); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index e8acbd544..99aedf9bf 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -6,8 +6,8 @@ import { ContainerDetailsViewModel } from '../../../models/container'; angular.module('portainer.docker') -.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'HttpRequestHelper', -function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, HttpRequestHelper) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', +function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { $scope.create = create; @@ -26,7 +26,9 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai MemoryLimit: 0, MemoryReservation: 0, NodeName: null, - capabilities: [] + capabilities: [], + LogDriverName: '', + LogDriverOpts: [] }; $scope.extraNetworks = {}; @@ -117,6 +119,14 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai $scope.config.HostConfig.Devices.splice(index, 1); }; + $scope.addLogDriverOpt = function() { + $scope.formValues.LogDriverOpts.push({ name: '', value: ''}); + }; + + $scope.removeLogDriverOpt = function(index) { + $scope.formValues.LogDriverOpts.splice(index, 1); + }; + $scope.fromContainerMultipleNetworks = false; function prepareImageConfig(config) { @@ -264,6 +274,23 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } + function prepareLogDriver(config) { + var logOpts = {}; + if ($scope.formValues.LogDriverName) { + config.HostConfig.LogConfig = { Type: $scope.formValues.LogDriverName }; + if ($scope.formValues.LogDriverName !== 'none') { + $scope.formValues.LogDriverOpts.forEach(function (opt) { + if (opt.name) { + logOpts[opt.name] = opt.value; + } + }); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.HostConfig.LogConfig.Config = logOpts; + } + } + } + } + function prepareCapabilities(config) { var allowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === true;}); var notAllowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === false;}); @@ -285,6 +312,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai prepareLabels(config); prepareDevices(config); prepareResources(config); + prepareLogDriver(config); prepareCapabilities(config); return config; } @@ -575,6 +603,11 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai Notifications.error('Failure', err, 'Unable to retrieve application settings'); }); + PluginService.loggingPlugins(apiVersion < 1.25) + .then(function success(loggingDrivers) { + $scope.availableLoggingDrivers = loggingDrivers; + }); + var userDetails = Authentication.getUserDetails(); $scope.isAdmin = userDetails.role === 1; } diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index a14c8f1d4..3f61e44fb 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -146,7 +146,7 @@