diff --git a/README.md b/README.md index 7b2e9e096..cee536856 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [](https://hub.docker.com/r/portainer/portainer/) [](http://microbadger.com/images/portainer/portainer "Image size") [](http://portainer.readthedocs.io/en/stable/?badge=stable) -[](https://semaphoreci.com/portainer/portainer) +[](https://semaphoreci.com/portainer/portainer-ci) [](https://codeclimate.com/github/portainer/portainer) [](https://portainer.io/slack/) [](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 @@ +
+ + Id + + + + | +
+
+ State
+
+
+
+
+ Filter
+
+ Filter
+
+
+
+ |
+ + + + + Created + + | +||||||
---|---|---|---|---|---|---|---|---|
+ + {{ item.Id | truncate: 32}} + | ++ {{ item.Status }} + + {{ item.Status }} + | ++ {{item.Created | getisodatefromtimestamp}} + | +||||||
Loading... | +||||||||
No jobs available. | +