From 587e2fa673416201917d19b09db40aa31de874ac Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 15 Oct 2017 19:24:40 +0200 Subject: [PATCH] feat(stacks): add support for stack deploy (#1280) --- api/bolt/datastore.go | 6 +- api/bolt/internal/internal.go | 10 + api/bolt/stack_service.go | 138 ++++ api/cmd/portainer/main.go | 19 +- api/errors.go | 7 + api/exec/stack_manager.go | 76 +++ api/file/file.go | 84 ++- api/git/git.go | 25 + api/http/handler/dockerhub.go | 2 +- api/http/handler/handler.go | 3 + api/http/handler/resource_control.go | 2 + api/http/handler/stack.go | 609 ++++++++++++++++++ api/http/handler/upload.go | 2 +- api/http/handler/websocket.go | 2 +- api/http/proxy/access_control.go | 137 ++++ api/http/proxy/containers.go | 129 +++- api/http/proxy/decorator.go | 138 ---- api/http/proxy/factory.go | 13 + api/http/proxy/filter.go | 185 ------ api/http/proxy/networks.go | 90 ++- api/http/proxy/secrets.go | 62 +- api/http/proxy/service.go | 64 -- api/http/proxy/services.go | 142 ++++ api/http/proxy/tasks.go | 46 +- api/http/proxy/transport.go | 21 +- api/http/proxy/utils.go | 32 - api/http/proxy/volumes.go | 90 ++- api/http/server.go | 13 +- api/portainer.go | 42 +- app/__module.js | 3 + app/components/containers/containers.html | 28 +- .../createStack/createStackController.js | 119 ++++ app/components/createStack/createstack.html | 156 +++++ app/components/dashboard/dashboard.html | 26 + .../dashboard/dashboardController.js | 16 +- app/components/networks/networks.html | 12 +- app/components/service/serviceController.js | 2 +- app/components/services/services.html | 20 +- app/components/sidebar/sidebar.html | 3 + app/components/stack/stack.html | 54 ++ app/components/stack/stackController.js | 78 +++ app/components/stacks/stacks.html | 120 ++++ app/components/stacks/stacksController.js | 103 +++ app/components/volumes/volumes.html | 8 + .../porAccessControlPanel.html | 13 +- .../serviceList/por-service-list.js | 8 + .../serviceList/porServiceList.html | 98 +++ app/directives/serviceList/porServiceList.js | 21 + app/directives/taskList/por-task-list.js | 8 + app/directives/taskList/porTaskList.html | 78 +++ app/directives/taskList/porTaskList.js | 19 + app/filters/filters.js | 28 +- app/helpers/serviceHelper.js | 246 +++---- app/helpers/stackHelper.js | 21 + app/models/api/stack.js | 9 + app/models/docker/container.js | 6 + app/models/docker/network.js | 7 + app/models/docker/service.js | 4 + app/models/docker/swarm.js | 3 + app/models/docker/volume.js | 5 + app/rest/api/stack.js | 15 + app/rest/docker/service.js | 2 +- app/routes.js | 39 ++ app/services/api/stackService.js | 162 +++++ app/services/codeMirror.js | 21 + app/services/docker/containerService.js | 10 +- app/services/docker/serviceService.js | 6 +- app/services/docker/swarmService.js | 22 + app/services/docker/taskService.js | 23 +- app/services/fileUpload.js | 7 +- app/services/notifications.js | 13 +- bower.json | 4 +- build.sh | 5 +- build/download_docker_binary.sh | 22 + build/windows/microsoftservercore/Dockerfile | 11 - gruntfile.js | 34 +- vendor.yml | 14 + 77 files changed, 3219 insertions(+), 702 deletions(-) create mode 100644 api/bolt/stack_service.go create mode 100644 api/exec/stack_manager.go create mode 100644 api/git/git.go create mode 100644 api/http/handler/stack.go delete mode 100644 api/http/proxy/decorator.go delete mode 100644 api/http/proxy/filter.go delete mode 100644 api/http/proxy/service.go create mode 100644 api/http/proxy/services.go delete mode 100644 api/http/proxy/utils.go create mode 100644 app/components/createStack/createStackController.js create mode 100644 app/components/createStack/createstack.html create mode 100644 app/components/stack/stack.html create mode 100644 app/components/stack/stackController.js create mode 100644 app/components/stacks/stacks.html create mode 100644 app/components/stacks/stacksController.js create mode 100644 app/directives/serviceList/por-service-list.js create mode 100644 app/directives/serviceList/porServiceList.html create mode 100644 app/directives/serviceList/porServiceList.js create mode 100644 app/directives/taskList/por-task-list.js create mode 100644 app/directives/taskList/porTaskList.html create mode 100644 app/directives/taskList/porTaskList.js create mode 100644 app/helpers/stackHelper.js create mode 100644 app/models/api/stack.js create mode 100644 app/models/docker/swarm.js create mode 100644 app/rest/api/stack.js create mode 100644 app/services/api/stackService.js create mode 100644 app/services/codeMirror.js create mode 100644 app/services/docker/swarmService.js create mode 100755 build/download_docker_binary.sh delete mode 100644 build/windows/microsoftservercore/Dockerfile diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index f0357a2a4..9511bdbc3 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -25,6 +25,7 @@ type Store struct { SettingsService *SettingsService RegistryService *RegistryService DockerHubService *DockerHubService + StackService *StackService db *bolt.DB checkForDataMigration bool @@ -41,6 +42,7 @@ const ( settingsBucketName = "settings" registryBucketName = "registries" dockerhubBucketName = "dockerhub" + stackBucketName = "stacks" ) // NewStore initializes a new Store and the associated services @@ -56,6 +58,7 @@ func NewStore(storePath string) (*Store, error) { SettingsService: &SettingsService{}, RegistryService: &RegistryService{}, DockerHubService: &DockerHubService{}, + StackService: &StackService{}, } store.UserService.store = store store.TeamService.store = store @@ -66,6 +69,7 @@ func NewStore(storePath string) (*Store, error) { store.SettingsService.store = store store.RegistryService.store = store store.DockerHubService.store = store + store.StackService.store = store _, err := os.Stat(storePath + "/" + databaseFileName) if err != nil && os.IsNotExist(err) { @@ -91,7 +95,7 @@ func (store *Store) Open() error { bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, - registryBucketName, dockerhubBucketName} + registryBucketName, dockerhubBucketName, stackBucketName} return db.Update(func(tx *bolt.Tx) error { diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 3378f93b7..2ee1027b5 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalStack encodes a stack to binary format. +func MarshalStack(stack *portainer.Stack) ([]byte, error) { + return json.Marshal(stack) +} + +// UnmarshalStack decodes a stack from a binary data. +func UnmarshalStack(data []byte, stack *portainer.Stack) error { + return json.Unmarshal(data, stack) +} + // MarshalRegistry encodes a registry to binary format. func MarshalRegistry(registry *portainer.Registry) ([]byte, error) { return json.Marshal(registry) diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go new file mode 100644 index 000000000..bdbaac791 --- /dev/null +++ b/api/bolt/stack_service.go @@ -0,0 +1,138 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// StackService represents a service for managing stacks. +type StackService struct { + store *Store +} + +// Stack returns a stack object by ID. +func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + value := bucket.Get([]byte(ID)) + if value == nil { + return portainer.ErrStackNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var stack portainer.Stack + err = internal.UnmarshalStack(data, &stack) + if err != nil { + return nil, err + } + return &stack, nil +} + +// Stacks returns an array containing all the stacks. +func (service *StackService) Stacks() ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack portainer.Stack + err := internal.UnmarshalStack(v, &stack) + if err != nil { + return err + } + stacks = append(stacks, stack) + } + + return nil + }) + if err != nil { + return nil, err + } + + return stacks, nil +} + +// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID. +func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack portainer.Stack + err := internal.UnmarshalStack(v, &stack) + if err != nil { + return err + } + if stack.SwarmID == id { + stacks = append(stacks, stack) + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return stacks, nil +} + +// CreateStack creates a new stack. +func (service *StackService) CreateStack(stack *portainer.Stack) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + + data, err := internal.MarshalStack(stack) + if err != nil { + return err + } + + err = bucket.Put([]byte(stack.ID), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateStack updates an stack. +func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { + data, err := internal.MarshalStack(stack) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + err = bucket.Put([]byte(ID), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteStack deletes an stack. +func (service *StackService) DeleteStack(ID portainer.StackID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + err := bucket.Delete([]byte(ID)) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 74aafde28..920f0b9fd 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -6,7 +6,9 @@ import ( "github.com/portainer/portainer/cli" "github.com/portainer/portainer/cron" "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/exec" "github.com/portainer/portainer/file" + "github.com/portainer/portainer/git" "github.com/portainer/portainer/http" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" @@ -54,6 +56,10 @@ func initStore(dataStorePath string) *bolt.Store { return store } +func initStackManager(assetsPath string) portainer.StackManager { + return exec.NewStackManager(assetsPath) +} + func initJWTService(authenticationEnabled bool) portainer.JWTService { if authenticationEnabled { jwtService, err := jwt.NewService() @@ -73,6 +79,10 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } +func initGitService() portainer.GitService { + return &git.Service{} +} + func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { authorizeEndpointMgmt := true if externalEnpointFile != "" { @@ -165,12 +175,16 @@ func main() { store := initStore(*flags.Data) defer store.Close() + stackManager := initStackManager(*flags.Assets) + jwtService := initJWTService(!*flags.NoAuth) cryptoService := initCryptoService() ldapService := initLDAPService() + gitService := initGitService() + authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) err := initSettings(store.SettingsService, flags) @@ -215,7 +229,7 @@ func main() { adminPasswordHash := "" if *flags.AdminPasswordFile != "" { - content, err := file.GetStringFromFile(*flags.AdminPasswordFile) + content, err := fileService.GetFileContent(*flags.AdminPasswordFile) if err != nil { log.Fatal(err) } @@ -263,10 +277,13 @@ func main() { SettingsService: store.SettingsService, RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, + StackService: store.StackService, + StackManager: stackManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, LDAPService: ldapService, + GitService: gitService, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, diff --git a/api/errors.go b/api/errors.go index aefd967b7..ce2a9d10a 100644 --- a/api/errors.go +++ b/api/errors.go @@ -50,6 +50,13 @@ const ( ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") ) +// Stack errors +const ( + ErrStackNotFound = Error("Stack not found") + ErrStackAlreadyExists = Error("A stack already exists with this name") + ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") +) + // Version errors. const ( ErrDBVersionNotFound = Error("DB version not found") diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go new file mode 100644 index 000000000..3f8b60b79 --- /dev/null +++ b/api/exec/stack_manager.go @@ -0,0 +1,76 @@ +package exec + +import ( + "bytes" + "os/exec" + "path" + "runtime" + + "github.com/portainer/portainer" +) + +// StackManager represents a service for managing stacks. +type StackManager struct { + binaryPath string +} + +// NewStackManager initializes a new StackManager service. +func NewStackManager(binaryPath string) *StackManager { + return &StackManager{ + binaryPath: binaryPath, + } +} + +// Deploy will execute the Docker stack deploy command +func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + return runCommandAndCaptureStdErr(command, args) +} + +// Remove will execute the Docker stack rm command +func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + args = append(args, "stack", "rm", stack.Name) + return runCommandAndCaptureStdErr(command, args) +} + +func runCommandAndCaptureStdErr(command string, args []string) error { + var stderr bytes.Buffer + cmd := exec.Command(command, args...) + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return portainer.Error(stderr.String()) + } + + return nil +} + +func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) { + // Assume Linux as a default + command := path.Join(binaryPath, "docker") + + if runtime.GOOS == "windows" { + command = path.Join(binaryPath, "docker.exe") + } + + args := make([]string, 0) + args = append(args, "-H", endpoint.URL) + + if endpoint.TLSConfig.TLS { + args = append(args, "--tls") + + if !endpoint.TLSConfig.TLSSkipVerify { + args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath) + } + + if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" { + args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath) + } + } + + return command, args +} diff --git a/api/file/file.go b/api/file/file.go index 80e8d7b12..03de12d23 100644 --- a/api/file/file.go +++ b/api/file/file.go @@ -1,6 +1,7 @@ package file import ( + "bytes" "io/ioutil" "github.com/portainer/portainer" @@ -21,6 +22,10 @@ const ( TLSCertFile = "cert.pem" // TLSKeyFile represents the name on disk for a TLS key file. TLSKeyFile = "key.pem" + // ComposeStorePath represents the subfolder where compose files are stored in the file store folder. + ComposeStorePath = "compose" + // ComposeFileDefaultName represents the default name of a compose file. + ComposeFileDefaultName = "docker-compose.yml" ) // Service represents a service for managing files and directories. @@ -50,9 +55,65 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStoreIfNotExist(ComposeStorePath) + if err != nil { + return nil, err + } + return service, nil } +// RemoveDirectory removes a directory on the filesystem. +func (service *Service) RemoveDirectory(directoryPath string) error { + return os.RemoveAll(directoryPath) +} + +// GetStackProjectPath returns the absolute path on the FS for a stack based +// on its identifier. +func (service *Service) GetStackProjectPath(stackIdentifier string) string { + return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier) +} + +// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) { + stackStorePath := path.Join(ComposeStorePath, stackIdentifier) + err := service.createDirectoryInStoreIfNotExist(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName) + data := []byte(stackFileContent) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, stackStorePath), nil +} + +// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) { + stackStorePath := path.Join(ComposeStorePath, stackIdentifier) + err := service.createDirectoryInStoreIfNotExist(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, stackStorePath), nil +} + // StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { storePath := path.Join(TLSStorePath, folder) @@ -130,6 +191,16 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT return nil } +// GetFileContent returns a string content from file. +func (service *Service) GetFileContent(filePath string) (string, error) { + content, err := ioutil.ReadFile(filePath) + if err != nil { + return "", err + } + + return string(content), nil +} + // createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system. func (service *Service) createDirectoryInStoreIfNotExist(name string) error { path := path.Join(service.fileStorePath, name) @@ -153,24 +224,17 @@ func createDirectoryIfNotExist(path string, mode uint32) error { // createFile creates a new file in the file store with the content from r. func (service *Service) createFileInStore(filePath string, r io.Reader) error { path := path.Join(service.fileStorePath, filePath) + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer out.Close() + _, err = io.Copy(out, r) if err != nil { return err } + return nil } - -// GetStringFromFile returns a string content from file. -func GetStringFromFile(filePath string) (string, error) { - content, err := ioutil.ReadFile(filePath) - if err != nil { - return "", err - } - - return string(content), nil -} diff --git a/api/git/git.go b/api/git/git.go new file mode 100644 index 000000000..8758363b9 --- /dev/null +++ b/api/git/git.go @@ -0,0 +1,25 @@ +package git + +import ( + "gopkg.in/src-d/go-git.v4" +) + +// Service represents a service for managing Git. +type Service struct{} + +// NewService initializes a new service. +func NewService(dataStorePath string) (*Service, error) { + service := &Service{} + + return service, nil +} + +// CloneRepository clones a git repository using the specified URL in the specified +// destination folder. +func (service *Service) CloneRepository(url, destination string) error { + _, err := git.PlainClone(destination, false, &git.CloneOptions{ + URL: url, + }) + + return err +} diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go index 9da51c90e..a5ff36b57 100644 --- a/api/http/handler/dockerhub.go +++ b/api/http/handler/dockerhub.go @@ -22,7 +22,7 @@ type DockerHubHandler struct { DockerHubService portainer.DockerHubService } -// NewDockerHubHandler returns a new instance of NewDockerHubHandler. +// NewDockerHubHandler returns a new instance of DockerHubHandler. func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { h := &DockerHubHandler{ Router: mux.NewRouter(), diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 4a83f6743..5128a125a 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -20,6 +20,7 @@ type Handler struct { RegistryHandler *RegistryHandler DockerHubHandler *DockerHubHandler ResourceHandler *ResourceHandler + StackHandler *StackHandler StatusHandler *StatusHandler SettingsHandler *SettingsHandler TemplatesHandler *TemplatesHandler @@ -49,6 +50,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(r.URL.Path, "/api/endpoints"): if strings.Contains(r.URL.Path, "/docker") { http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) + } else if strings.Contains(r.URL.Path, "/stacks") { + http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r) } else { http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 623e595e9..779431516 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -82,6 +82,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.NetworkResourceControl case "secret": resourceControlType = portainer.SecretResourceControl + case "stack": + resourceControlType = portainer.StackResourceControl default: httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) return diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go new file mode 100644 index 000000000..e3e13930c --- /dev/null +++ b/api/http/handler/stack.go @@ -0,0 +1,609 @@ +package handler + +import ( + "encoding/json" + "path" + "strconv" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/file" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// StackHandler represents an HTTP API handler for managing Stack. +type StackHandler struct { + *mux.Router + Logger *log.Logger + FileService portainer.FileService + GitService portainer.GitService + StackService portainer.StackService + EndpointService portainer.EndpointService + ResourceControlService portainer.ResourceControlService + StackManager portainer.StackManager +} + +// NewStackHandler returns a new instance of StackHandler. +func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { + h := &StackHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/{endpointId}/stacks", + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) + h.Handle("/{endpointId}/stacks", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet) + h.Handle("/{endpointId}/stacks/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet) + h.Handle("/{endpointId}/stacks/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete) + h.Handle("/{endpointId}/stacks/{id}", + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut) + h.Handle("/{endpointId}/stacks/{id}/stackfile", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet) + return h +} + +type ( + postStacksRequest struct { + Name string `valid:"required"` + SwarmID string `valid:"required"` + StackFileContent string `valid:""` + GitRepository string `valid:""` + PathInRepository string `valid:""` + } + postStacksResponse struct { + ID string `json:"Id"` + } + getStackFileResponse struct { + StackFileContent string `json:"StackFileContent"` + } + putStackRequest struct { + StackFileContent string `valid:"required"` + } +) + +// handlePostStacks handles POST requests on /:endpointId/stacks?method= +func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) { + method := r.FormValue("method") + if method == "" { + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + return + } + + if method == "string" { + handler.handlePostStacksStringMethod(w, r) + } else if method == "repository" { + handler.handlePostStacksRepositoryMethod(w, r) + } else if method == "file" { + handler.handlePostStacksFileMethod(w, r) + } else { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } +} + +func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + endpointID := portainer.EndpointID(id) + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req postStacksRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + stackName := req.Name + if stackName == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + stackFileContent := req.StackFileContent + if stackFileContent == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + swarmID := req.SwarmID + if swarmID == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + stacks, err := handler.StackService.Stacks() + if err != nil && err != portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, stackName) { + httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + + stack := &portainer.Stack{ + ID: portainer.StackID(stackName + "_" + swarmID), + Name: stackName, + SwarmID: swarmID, + EntryPoint: file.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + stack.ProjectPath = projectPath + + err = handler.StackService.CreateStack(stack) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) +} + +func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + endpointID := portainer.EndpointID(id) + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req postStacksRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + stackName := req.Name + if stackName == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + swarmID := req.SwarmID + if swarmID == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + if req.GitRepository == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + if req.PathInRepository == "" { + req.PathInRepository = file.ComposeFileDefaultName + } + + stacks, err := handler.StackService.Stacks() + if err != nil && err != portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, stackName) { + httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + + stack := &portainer.Stack{ + ID: portainer.StackID(stackName + "_" + swarmID), + Name: stackName, + SwarmID: swarmID, + EntryPoint: req.PathInRepository, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + // Ensure projectPath is empty + err = handler.FileService.RemoveDirectory(projectPath) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.GitService.CloneRepository(req.GitRepository, projectPath) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) +} + +func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + endpointID := portainer.EndpointID(id) + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stackName := r.FormValue("Name") + if stackName == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + swarmID := r.FormValue("SwarmID") + if swarmID == "" { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + stackFile, _, err := r.FormFile("file") + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + defer stackFile.Close() + + stacks, err := handler.StackService.Stacks() + if err != nil && err != portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, stackName) { + httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) + return + } + } + + stack := &portainer.Stack{ + ID: portainer.StackID(stackName + "_" + swarmID), + Name: stackName, + SwarmID: swarmID, + EntryPoint: file.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + stack.ProjectPath = projectPath + + err = handler.StackService.CreateStack(stack) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) +} + +// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId= +func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) { + swarmID := r.FormValue("swarmId") + + vars := mux.Vars(r) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + id, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + endpointID := portainer.EndpointID(id) + + _, err = handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var stacks []portainer.Stack + if swarmID == "" { + stacks, err = handler.StackService.Stacks() + } else { + stacks, err = handler.StackService.StacksBySwarmID(swarmID) + } + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + resourceControls, err := handler.ResourceControlService.ResourceControls() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, + securityContext.UserID, securityContext.UserMemberships) + + encodeJSON(w, filteredStacks, handler.Logger) +} + +// handleGetStack handles GET requests on /:endpointId/stacks/:id +func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + stackID := vars["id"] + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpointID, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + } + + encodeJSON(w, extendedStack, handler.Logger) +} + +// handlePutStack handles PUT requests on /:endpointId/stacks/:id +func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + stackID := vars["id"] + + endpointID, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req putStackRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile +func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + stackID := vars["id"] + + endpointID, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger) +} + +// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id +func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + stackID := vars["id"] + + endpointID, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackManager.Remove(stack, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.StackService.DeleteStack(portainer.StackID(stackID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/handler/upload.go b/api/http/handler/upload.go index 7395fe888..0343d1a2f 100644 --- a/api/http/handler/upload.go +++ b/api/http/handler/upload.go @@ -30,7 +30,7 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { return h } -// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder +// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder= func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) certificate := vars["certificate"] diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket.go index b57f02806..70fc77d9d 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket.go @@ -69,7 +69,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { host = endpointURL.Path } - // Should not be managed here + // TODO: Should not be managed here var tlsConfig *tls.Config if endpoint.TLSConfig.TLS { tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index eb26661b5..eece43d9c 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -2,6 +2,83 @@ package proxy import "github.com/portainer/portainer" +type ( + // ExtendedStack represents a stack combined with its associated access control + ExtendedStack struct { + portainer.Stack + ResourceControl portainer.ResourceControl `json:"ResourceControl"` + } +) + +// applyResourceAccessControl returns an optionally decorated object as the first return value and the +// access level for the user (granted or denied) as the second return value. +// It will retrieve an identifier from the labels object. If an identifier exists, it will check for +// an existing resource control associated to it. +// Returns a decorated object and authorized access (true) when a resource control is found and the user can access the resource. +// Returns the original object and authorized access (true) when no resource control is found. +// Returns the original object and denied access (false) when a resource control is found and the user cannot access the resource. +func applyResourceAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string, + context *restrictedOperationContext) (map[string]interface{}, bool) { + + if labelsObject != nil && labelsObject[labelIdentifier] != nil { + resourceIdentifier := labelsObject[labelIdentifier].(string) + return applyResourceAccessControl(resourceObject, resourceIdentifier, context) + } + return resourceObject, true +} + +// applyResourceAccessControl returns an optionally decorated object as the first return value and the +// access level for the user (granted or denied) as the second return value. +// Returns a decorated object and authorized access (true) when a resource control is found to the specified resource +// identifier and the user can access the resource. +// Returns the original object and authorized access (true) when no resource control is found for the specified +// resource identifier. +// Returns the original object and denied access (false) when a resource control is associated to the resource +// and the user cannot access the resource. +func applyResourceAccessControl(resourceObject map[string]interface{}, resourceIdentifier string, + context *restrictedOperationContext) (map[string]interface{}, bool) { + + authorizedAccess := true + + resourceControl := getResourceControlByResourceID(resourceIdentifier, context.resourceControls) + if resourceControl != nil { + if context.isAdmin || canUserAccessResource(context.userID, context.userTeamIDs, resourceControl) { + resourceObject = decorateObject(resourceObject, resourceControl) + } else { + authorizedAccess = false + } + } + + return resourceObject, authorizedAccess +} + +// decorateResourceWithAccessControlFromLabel will retrieve an identifier from the labels object. If an identifier exists, +// it will check for an existing resource control associated to it. If a resource control is found, the resource object will be +// decorated. If no identifier can be found in the labels or no resource control is associated to the identifier, the resource +// object will not be changed. +func decorateResourceWithAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string, + resourceControls []portainer.ResourceControl) map[string]interface{} { + + if labelsObject != nil && labelsObject[labelIdentifier] != nil { + resourceIdentifier := labelsObject[labelIdentifier].(string) + resourceObject = decorateResourceWithAccessControl(resourceObject, resourceIdentifier, resourceControls) + } + + return resourceObject +} + +// decorateResourceWithAccessControl will check if a resource control is associated to the specified resource identifier. +// If a resource control is found, the resource object will be decorated, otherwise it will not be changed. +func decorateResourceWithAccessControl(resourceObject map[string]interface{}, resourceIdentifier string, + resourceControls []portainer.ResourceControl) map[string]interface{} { + + resourceControl := getResourceControlByResourceID(resourceIdentifier, resourceControls) + if resourceControl != nil { + return decorateObject(resourceObject, resourceControl) + } + return resourceObject +} + func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { for _, authorizedUserAccess := range resourceControl.UserAccesses { if userID == authorizedUserAccess.UserID { @@ -19,3 +96,63 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team return false } + +func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { + metadata := make(map[string]interface{}) + metadata["ResourceControl"] = resourceControl + object["Portainer"] = metadata + return object +} + +func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { + for _, resourceControl := range resourceControls { + if resourceID == resourceControl.ResourceID { + return &resourceControl + } + for _, subResourceID := range resourceControl.SubResourceIDs { + if resourceID == subResourceID { + return &resourceControl + } + } + } + return nil +} + +// CanAccessStack checks if a user can access a stack +func CanAccessStack(stack *portainer.Stack, resourceControl *portainer.ResourceControl, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range memberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + if canUserAccessResource(userID, userTeamIDs, resourceControl) { + return true + } + + return false +} + +// FilterStacks filters stacks based on user role and resource controls. +func FilterStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl, isAdmin bool, + userID portainer.UserID, memberships []portainer.TeamMembership) []ExtendedStack { + + filteredStacks := make([]ExtendedStack, 0) + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range memberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + for _, stack := range stacks { + extendedStack := ExtendedStack{stack, portainer.ResourceControl{}} + resourceControl := getResourceControlByResourceID(stack.Name, resourceControls) + if resourceControl == nil { + filteredStacks = append(filteredStacks, extendedStack) + } else if resourceControl != nil && (isAdmin || canUserAccessResource(userID, userTeamIDs, resourceControl)) { + extendedStack.ResourceControl = *resourceControl + filteredStacks = append(filteredStacks, extendedStack) + } + } + + return filteredStacks +} diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index 964dab1f6..6992ae623 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -11,6 +11,7 @@ const ( ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") containerIdentifier = "Id" containerLabelForServiceIdentifier = "com.docker.swarm.service.id" + containerLabelForStackIdentifier = "com.docker.stack.namespace" ) // containerListOperation extracts the response as a JSON object, loop through the containers array @@ -27,8 +28,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec if executor.operationContext.isAdmin { responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls) } else { - responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls, - executor.operationContext.userID, executor.operationContext.userTeamIDs) + responseArray, err = filterContainerList(responseArray, executor.operationContext) } if err != nil { return err @@ -58,30 +58,22 @@ func containerInspectOperation(request *http.Request, response *http.Response, e if responseObject[containerIdentifier] == nil { return ErrDockerContainerIdentifierNotFound } - containerID := responseObject[containerIdentifier].(string) - resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, - executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } + containerID := responseObject[containerIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, containerID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) } containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) - if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { - serviceID := containerLabels[containerLabelForServiceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, - executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } - } + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForServiceIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) } return rewriteResponse(response, responseObject, http.StatusOK) @@ -106,3 +98,96 @@ func extractContainerLabelsFromContainerListObject(responseObject map[string]int containerLabelsObject := extractJSONField(responseObject, "Labels") return containerLabelsObject } + +// decorateContainerList loops through all containers and decorates any container with an existing resource control. +// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label). +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedContainerData := make([]interface{}, 0) + + for _, container := range containerData { + + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + containerObject = decorateResourceWithAccessControl(containerObject, containerID, resourceControls) + + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls) + + decoratedContainerData = append(decoratedContainerData, containerObject) + } + + return decoratedContainerData, nil +} + +// filterContainerList loops through all containers and filters public containers (no associated resource control) +// as well as authorized containers (access granted to the user based on existing resource control). +// Authorized containers are decorated during the process. +// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label). +// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func filterContainerList(containerData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + if containerObject[containerIdentifier] == nil { + return nil, ErrDockerContainerIdentifierNotFound + } + + containerID := containerObject[containerIdentifier].(string) + containerObject, access := applyResourceAccessControl(containerObject, containerID, context) + if access { + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) + if access { + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context) + if access { + filteredContainerData = append(filteredContainerData, containerObject) + } + } + } + } + + return filteredContainerData, nil +} + +// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains +// any labels in the labels black list. +func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) { + filteredContainerData := make([]interface{}, 0) + + for _, container := range containerData { + containerObject := container.(map[string]interface{}) + + containerLabels := extractContainerLabelsFromContainerListObject(containerObject) + if containerLabels != nil { + if !containerHasBlackListedLabel(containerLabels, labelBlackList) { + filteredContainerData = append(filteredContainerData, containerObject) + } + } else { + filteredContainerData = append(filteredContainerData, containerObject) + } + } + + return filteredContainerData, nil +} + +func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool { + for key, value := range containerLabels { + labelName := key + labelValue := value.(string) + + for _, blackListedLabel := range labelBlackList { + if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { + return true + } + } + } + + return false +} diff --git a/api/http/proxy/decorator.go b/api/http/proxy/decorator.go deleted file mode 100644 index ff075cc69..000000000 --- a/api/http/proxy/decorator.go +++ /dev/null @@ -1,138 +0,0 @@ -package proxy - -import "github.com/portainer/portainer" - -// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control. -// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList -func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedVolumeData := make([]interface{}, 0) - - for _, volume := range volumeData { - - volumeObject := volume.(map[string]interface{}) - if volumeObject[volumeIdentifier] == nil { - return nil, ErrDockerVolumeIdentifierNotFound - } - - volumeID := volumeObject[volumeIdentifier].(string) - resourceControl := getResourceControlByResourceID(volumeID, resourceControls) - if resourceControl != nil { - volumeObject = decorateObject(volumeObject, resourceControl) - } - decoratedVolumeData = append(decoratedVolumeData, volumeObject) - } - - return decoratedVolumeData, nil -} - -// decorateContainerList loops through all containers and will decorate any container with an existing resource control. -// Check is based on the container ID and optional Swarm service ID. -// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList -func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedContainerData := make([]interface{}, 0) - - for _, container := range containerData { - - containerObject := container.(map[string]interface{}) - if containerObject[containerIdentifier] == nil { - return nil, ErrDockerContainerIdentifierNotFound - } - - containerID := containerObject[containerIdentifier].(string) - resourceControl := getResourceControlByResourceID(containerID, resourceControls) - if resourceControl != nil { - containerObject = decorateObject(containerObject, resourceControl) - } - - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { - serviceID := containerLabels[containerLabelForServiceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, resourceControls) - if resourceControl != nil { - containerObject = decorateObject(containerObject, resourceControl) - } - } - - decoratedContainerData = append(decoratedContainerData, containerObject) - } - - return decoratedContainerData, nil -} - -// decorateServiceList loops through all services and will decorate any service with an existing resource control. -// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList -func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedServiceData := make([]interface{}, 0) - - for _, service := range serviceData { - - serviceObject := service.(map[string]interface{}) - if serviceObject[serviceIdentifier] == nil { - return nil, ErrDockerServiceIdentifierNotFound - } - - serviceID := serviceObject[serviceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, resourceControls) - if resourceControl != nil { - serviceObject = decorateObject(serviceObject, resourceControl) - } - decoratedServiceData = append(decoratedServiceData, serviceObject) - } - - return decoratedServiceData, nil -} - -// decorateNetworkList loops through all networks and will decorate any network with an existing resource control. -// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList -func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedNetworkData := make([]interface{}, 0) - - for _, network := range networkData { - - networkObject := network.(map[string]interface{}) - if networkObject[networkIdentifier] == nil { - return nil, ErrDockerNetworkIdentifierNotFound - } - - networkID := networkObject[networkIdentifier].(string) - resourceControl := getResourceControlByResourceID(networkID, resourceControls) - if resourceControl != nil { - networkObject = decorateObject(networkObject, resourceControl) - } - - decoratedNetworkData = append(decoratedNetworkData, networkObject) - } - - return decoratedNetworkData, nil -} - -// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control. -// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList -func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { - decoratedSecretData := make([]interface{}, 0) - - for _, secret := range secretData { - - secretObject := secret.(map[string]interface{}) - if secretObject[secretIdentifier] == nil { - return nil, ErrDockerSecretIdentifierNotFound - } - - secretID := secretObject[secretIdentifier].(string) - resourceControl := getResourceControlByResourceID(secretID, resourceControls) - if resourceControl != nil { - secretObject = decorateObject(secretObject, resourceControl) - } - - decoratedSecretData = append(decoratedSecretData, secretObject) - } - - return decoratedSecretData, nil -} - -func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { - metadata := make(map[string]interface{}) - metadata["ResourceControl"] = resourceControl - object["Portainer"] = metadata - return object -} diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 210ef54a0..c0a69109f 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -1,6 +1,7 @@ package proxy import ( + "net" "net/http" "net/http/httputil" "net/url" @@ -56,3 +57,15 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro proxy.Transport = transport return proxy } + +func newSocketTransport(socketPath string) *http.Transport { + return &http.Transport{ + Dial: func(proto, addr string) (conn net.Conn, err error) { + return net.Dial("unix", socketPath) + }, + } +} + +func newHTTPTransport() *http.Transport { + return &http.Transport{} +} diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go deleted file mode 100644 index 005b11469..000000000 --- a/api/http/proxy/filter.go +++ /dev/null @@ -1,185 +0,0 @@ -package proxy - -import "github.com/portainer/portainer" - -// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with -// any resource control giving access to the user (these volumes will be decorated). -// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList -func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredVolumeData := make([]interface{}, 0) - - for _, volume := range volumeData { - volumeObject := volume.(map[string]interface{}) - if volumeObject[volumeIdentifier] == nil { - return nil, ErrDockerVolumeIdentifierNotFound - } - - volumeID := volumeObject[volumeIdentifier].(string) - resourceControl := getResourceControlByResourceID(volumeID, resourceControls) - if resourceControl == nil { - filteredVolumeData = append(filteredVolumeData, volumeObject) - } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { - volumeObject = decorateObject(volumeObject, resourceControl) - filteredVolumeData = append(filteredVolumeData, volumeObject) - } - } - - return filteredVolumeData, nil -} - -// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with -// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated). -// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList -func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredContainerData := make([]interface{}, 0) - - for _, container := range containerData { - containerObject := container.(map[string]interface{}) - if containerObject[containerIdentifier] == nil { - return nil, ErrDockerContainerIdentifierNotFound - } - - containerID := containerObject[containerIdentifier].(string) - resourceControl := getResourceControlByResourceID(containerID, resourceControls) - if resourceControl == nil { - // check if container is part of a Swarm service - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { - serviceID := containerLabels[containerLabelForServiceIdentifier].(string) - serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls) - if serviceResourceControl == nil { - filteredContainerData = append(filteredContainerData, containerObject) - } else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) { - containerObject = decorateObject(containerObject, serviceResourceControl) - filteredContainerData = append(filteredContainerData, containerObject) - } - } else { - filteredContainerData = append(filteredContainerData, containerObject) - } - } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { - containerObject = decorateObject(containerObject, resourceControl) - filteredContainerData = append(filteredContainerData, containerObject) - } - } - - return filteredContainerData, nil -} - -// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains -// any labels in the labels black list. -func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) { - filteredContainerData := make([]interface{}, 0) - - for _, container := range containerData { - containerObject := container.(map[string]interface{}) - - containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - if containerLabels != nil { - if !containerHasBlackListedLabel(containerLabels, labelBlackList) { - filteredContainerData = append(filteredContainerData, containerObject) - } - } else { - filteredContainerData = append(filteredContainerData, containerObject) - } - } - - return filteredContainerData, nil -} - -// filterServiceList loops through all services, filters services without any resource control (public resources) or with -// any resource control giving access to the user (these services will be decorated). -// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList -func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredServiceData := make([]interface{}, 0) - - for _, service := range serviceData { - serviceObject := service.(map[string]interface{}) - if serviceObject[serviceIdentifier] == nil { - return nil, ErrDockerServiceIdentifierNotFound - } - - serviceID := serviceObject[serviceIdentifier].(string) - resourceControl := getResourceControlByResourceID(serviceID, resourceControls) - if resourceControl == nil { - filteredServiceData = append(filteredServiceData, serviceObject) - } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { - serviceObject = decorateObject(serviceObject, resourceControl) - filteredServiceData = append(filteredServiceData, serviceObject) - } - } - - return filteredServiceData, nil -} - -// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with -// any resource control giving access to the user (these networks will be decorated). -// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList -func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredNetworkData := make([]interface{}, 0) - - for _, network := range networkData { - networkObject := network.(map[string]interface{}) - if networkObject[networkIdentifier] == nil { - return nil, ErrDockerNetworkIdentifierNotFound - } - - networkID := networkObject[networkIdentifier].(string) - resourceControl := getResourceControlByResourceID(networkID, resourceControls) - if resourceControl == nil { - filteredNetworkData = append(filteredNetworkData, networkObject) - } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { - networkObject = decorateObject(networkObject, resourceControl) - filteredNetworkData = append(filteredNetworkData, networkObject) - } - } - - return filteredNetworkData, nil -} - -// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with -// any resource control giving access to the user (these secrets will be decorated). -// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList -func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredSecretData := make([]interface{}, 0) - - for _, secret := range secretData { - secretObject := secret.(map[string]interface{}) - if secretObject[secretIdentifier] == nil { - return nil, ErrDockerSecretIdentifierNotFound - } - - secretID := secretObject[secretIdentifier].(string) - resourceControl := getResourceControlByResourceID(secretID, resourceControls) - if resourceControl == nil { - filteredSecretData = append(filteredSecretData, secretObject) - } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { - secretObject = decorateObject(secretObject, resourceControl) - filteredSecretData = append(filteredSecretData, secretObject) - } - } - - return filteredSecretData, nil -} - -// filterTaskList loops through all tasks, filters tasks without any resource control (public resources) or with -// any resource control giving access to the user based on the associated service identifier. -// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList -func filterTaskList(taskData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { - filteredTaskData := make([]interface{}, 0) - - for _, task := range taskData { - taskObject := task.(map[string]interface{}) - if taskObject[taskServiceIdentifier] == nil { - return nil, ErrDockerTaskServiceIdentifierNotFound - } - - serviceID := taskObject[taskServiceIdentifier].(string) - - resourceControl := getResourceControlByResourceID(serviceID, resourceControls) - if resourceControl == nil || (resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl)) { - filteredTaskData = append(filteredTaskData, taskObject) - } - } - - return filteredTaskData, nil -} diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go index 2e2549408..aa1bcea70 100644 --- a/api/http/proxy/networks.go +++ b/api/http/proxy/networks.go @@ -10,6 +10,7 @@ const ( // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") networkIdentifier = "Id" + networkLabelForStackIdentifier = "com.docker.stack.namespace" ) // networkListOperation extracts the response as a JSON object, loop through the networks array @@ -26,8 +27,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut if executor.operationContext.isAdmin { responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) } else { - responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls, - executor.operationContext.userID, executor.operationContext.userTeamIDs) + responseArray, err = filterNetworkList(responseArray, executor.operationContext) } if err != nil { return err @@ -50,17 +50,85 @@ func networkInspectOperation(request *http.Request, response *http.Response, exe if responseObject[networkIdentifier] == nil { return ErrDockerNetworkIdentifierNotFound } - networkID := responseObject[networkIdentifier].(string) - resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, - executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } + networkID := responseObject[networkIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, networkID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + networkLabels := extractNetworkLabelsFromNetworkInspectObject(responseObject) + responseObject, access = applyResourceAccessControlFromLabel(networkLabels, responseObject, networkLabelForStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) } return rewriteResponse(response, responseObject, http.StatusOK) } + +// extractNetworkLabelsFromNetworkInspectObject retrieve the Labels of the network if present. +// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect +func extractNetworkLabelsFromNetworkInspectObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + return extractJSONField(responseObject, "Labels") +} + +// extractNetworkLabelsFromNetworkListObject retrieve the Labels of the network if present. +// Network schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func extractNetworkLabelsFromNetworkListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + return extractJSONField(responseObject, "Labels") +} + +// decorateNetworkList loops through all networks and decorates any network with an existing resource control. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + networkObject = decorateResourceWithAccessControl(networkObject, networkID, resourceControls) + + networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject) + networkObject = decorateResourceWithAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, resourceControls) + + decoratedNetworkData = append(decoratedNetworkData, networkObject) + } + + return decoratedNetworkData, nil +} + +// filterNetworkList loops through all networks and filters public networks (no associated resource control) +// as well as authorized networks (access granted to the user based on existing resource control). +// Authorized networks are decorated during the process. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func filterNetworkList(networkData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + networkObject, access := applyResourceAccessControl(networkObject, networkID, context) + if access { + networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject) + networkObject, access = applyResourceAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, context) + if access { + filteredNetworkData = append(filteredNetworkData, networkObject) + } + } + } + + return filteredNetworkData, nil +} diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go index d0001d3b9..cbef6ac84 100644 --- a/api/http/proxy/secrets.go +++ b/api/http/proxy/secrets.go @@ -27,8 +27,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo if executor.operationContext.isAdmin { responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) } else { - responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls, - executor.operationContext.userID, executor.operationContext.userTeamIDs) + responseArray, err = filterSecretList(responseArray, executor.operationContext) } if err != nil { return err @@ -51,17 +50,58 @@ func secretInspectOperation(request *http.Request, response *http.Response, exec if responseObject[secretIdentifier] == nil { return ErrDockerSecretIdentifierNotFound } - secretID := responseObject[secretIdentifier].(string) - resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, - executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } + secretID := responseObject[secretIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, secretID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) } return rewriteResponse(response, responseObject, http.StatusOK) } + +// decorateSecretList loops through all secrets and decorates any secret with an existing resource control. +// Resource controls checks are based on: resource identifier. +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + secretObject = decorateResourceWithAccessControl(secretObject, secretID, resourceControls) + + decoratedSecretData = append(decoratedSecretData, secretObject) + } + + return decoratedSecretData, nil +} + +// filterSecretList loops through all secrets and filters public secrets (no associated resource control) +// as well as authorized secrets (access granted to the user based on existing resource control). +// Authorized secrets are decorated during the process. +// Resource controls checks are based on: resource identifier. +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func filterSecretList(secretData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + secretObject, access := applyResourceAccessControl(secretObject, secretID, context) + if access { + filteredSecretData = append(filteredSecretData, secretObject) + } + } + + return filteredSecretData, nil +} diff --git a/api/http/proxy/service.go b/api/http/proxy/service.go deleted file mode 100644 index 317da9ebc..000000000 --- a/api/http/proxy/service.go +++ /dev/null @@ -1,64 +0,0 @@ -package proxy - -import ( - "net/http" - - "github.com/portainer/portainer" -) - -const ( - // ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier - ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found") - serviceIdentifier = "ID" -) - -// serviceListOperation extracts the response as a JSON array, loop through the service array -// decorate and/or filter the services based on resource controls before rewriting the response -func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { - var err error - // ServiceList response is a JSON array - // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList - responseArray, err := getResponseAsJSONArray(response) - if err != nil { - return err - } - - if executor.operationContext.isAdmin { - responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls) - } else { - responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) - } - if err != nil { - return err - } - - return rewriteResponse(response, responseArray, http.StatusOK) -} - -// serviceInspectOperation extracts the response as a JSON object, verify that the user -// has access to the service based on resource control and either rewrite an access denied response -// or a decorated service. -func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { - // ServiceInspect response is a JSON object - // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect - responseObject, err := getResponseAsJSONOBject(response) - if err != nil { - return err - } - - if responseObject[serviceIdentifier] == nil { - return ErrDockerServiceIdentifierNotFound - } - serviceID := responseObject[serviceIdentifier].(string) - - resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } - } - - return rewriteResponse(response, responseObject, http.StatusOK) -} diff --git a/api/http/proxy/services.go b/api/http/proxy/services.go new file mode 100644 index 000000000..fa010b022 --- /dev/null +++ b/api/http/proxy/services.go @@ -0,0 +1,142 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier + ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found") + serviceIdentifier = "ID" + serviceLabelForStackIdentifier = "com.docker.stack.namespace" +) + +// serviceListOperation extracts the response as a JSON array, loop through the service array +// decorate and/or filter the services based on resource controls before rewriting the response +func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + // ServiceList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterServiceList(responseArray, executor.operationContext) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// serviceInspectOperation extracts the response as a JSON object, verify that the user +// has access to the service based on resource control and either rewrite an access denied response +// or a decorated service. +func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // ServiceInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[serviceIdentifier] == nil { + return ErrDockerServiceIdentifierNotFound + } + + serviceID := responseObject[serviceIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, serviceID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + serviceLabels := extractServiceLabelsFromServiceInspectObject(responseObject) + responseObject, access = applyResourceAccessControlFromLabel(serviceLabels, responseObject, serviceLabelForStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// extractServiceLabelsFromServiceInspectObject retrieve the Labels of the service if present. +// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect +func extractServiceLabelsFromServiceInspectObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Spec.Labels + serviceSpecObject := extractJSONField(responseObject, "Spec") + if serviceSpecObject != nil { + return extractJSONField(serviceSpecObject, "Labels") + } + return nil +} + +// extractServiceLabelsFromServiceListObject retrieve the Labels of the service if present. +// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func extractServiceLabelsFromServiceListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Spec.Labels + serviceSpecObject := extractJSONField(responseObject, "Spec") + if serviceSpecObject != nil { + return extractJSONField(serviceSpecObject, "Labels") + } + return nil +} + +// decorateServiceList loops through all services and decorates any service with an existing resource control. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + serviceObject = decorateResourceWithAccessControl(serviceObject, serviceID, resourceControls) + + serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject) + serviceObject = decorateResourceWithAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, resourceControls) + + decoratedServiceData = append(decoratedServiceData, serviceObject) + } + + return decoratedServiceData, nil +} + +// filterServiceList loops through all services and filters public services (no associated resource control) +// as well as authorized services (access granted to the user based on existing resource control). +// Authorized services are decorated during the process. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func filterServiceList(serviceData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredServiceData := make([]interface{}, 0) + + for _, service := range serviceData { + serviceObject := service.(map[string]interface{}) + if serviceObject[serviceIdentifier] == nil { + return nil, ErrDockerServiceIdentifierNotFound + } + + serviceID := serviceObject[serviceIdentifier].(string) + serviceObject, access := applyResourceAccessControl(serviceObject, serviceID, context) + if access { + serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject) + serviceObject, access = applyResourceAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, context) + if access { + filteredServiceData = append(filteredServiceData, serviceObject) + } + } + } + + return filteredServiceData, nil +} diff --git a/api/http/proxy/tasks.go b/api/http/proxy/tasks.go index b9073458b..9187a0530 100644 --- a/api/http/proxy/tasks.go +++ b/api/http/proxy/tasks.go @@ -10,6 +10,7 @@ const ( // ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found") taskServiceIdentifier = "ServiceID" + taskLabelForStackIdentifier = "com.docker.stack.namespace" ) // taskListOperation extracts the response as a JSON object, loop through the tasks array @@ -25,8 +26,7 @@ func taskListOperation(request *http.Request, response *http.Response, executor } if !executor.operationContext.isAdmin { - responseArray, err = filterTaskList(responseArray, executor.operationContext.resourceControls, - executor.operationContext.userID, executor.operationContext.userTeamIDs) + responseArray, err = filterTaskList(responseArray, executor.operationContext) if err != nil { return err } @@ -34,3 +34,45 @@ func taskListOperation(request *http.Request, response *http.Response, executor return rewriteResponse(response, responseArray, http.StatusOK) } + +// extractTaskLabelsFromTaskListObject retrieve the Labels of the task if present. +// Task schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList +func extractTaskLabelsFromTaskListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Spec.ContainerSpec.Labels + taskSpecObject := extractJSONField(responseObject, "Spec") + if taskSpecObject != nil { + containerSpecObject := extractJSONField(taskSpecObject, "ContainerSpec") + if containerSpecObject != nil { + return extractJSONField(containerSpecObject, "Labels") + } + } + return nil +} + +// filterTaskList loops through all tasks and filters public tasks (no associated resource control) +// as well as authorized tasks (access granted to the user based on existing resource control). +// Resource controls checks are based on: service identifier, stack identifier (from label). +// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList +// any resource control giving access to the user based on the associated service identifier. +func filterTaskList(taskData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredTaskData := make([]interface{}, 0) + + for _, task := range taskData { + taskObject := task.(map[string]interface{}) + if taskObject[taskServiceIdentifier] == nil { + return nil, ErrDockerTaskServiceIdentifierNotFound + } + + serviceID := taskObject[taskServiceIdentifier].(string) + taskObject, access := applyResourceAccessControl(taskObject, serviceID, context) + if access { + taskLabels := extractTaskLabelsFromTaskListObject(taskObject) + taskObject, access = applyResourceAccessControlFromLabel(taskLabels, taskObject, taskLabelForStackIdentifier, context) + if access { + filteredTaskData = append(filteredTaskData, taskObject) + } + } + } + + return filteredTaskData, nil +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 83f746d0e..d5febb22a 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -1,7 +1,6 @@ package proxy import ( - "net" "net/http" "path" "strings" @@ -30,18 +29,6 @@ type ( restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error ) -func newSocketTransport(socketPath string) *http.Transport { - return &http.Transport{ - Dial: func(proto, addr string) (conn net.Conn, err error) { - return net.Dial("unix", socketPath) - }, - } -} - -func newHTTPTransport() *http.Transport { - return &http.Transport{} -} - func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { return p.proxyDockerRequest(request) } @@ -202,7 +189,13 @@ func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response } func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { - return p.administratorOperation(request) + switch requestPath := request.URL.Path; requestPath { + case "/swarm": + return p.executeDockerRequest(request) + default: + // assume /swarm/{action} + return p.administratorOperation(request) + } } func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) { diff --git a/api/http/proxy/utils.go b/api/http/proxy/utils.go deleted file mode 100644 index 7f85a624a..000000000 --- a/api/http/proxy/utils.go +++ /dev/null @@ -1,32 +0,0 @@ -package proxy - -import "github.com/portainer/portainer" - -func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { - for _, resourceControl := range resourceControls { - if resourceID == resourceControl.ResourceID { - return &resourceControl - } - for _, subResourceID := range resourceControl.SubResourceIDs { - if resourceID == subResourceID { - return &resourceControl - } - } - } - return nil -} - -func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool { - for key, value := range containerLabels { - labelName := key - labelValue := value.(string) - - for _, blackListedLabel := range labelBlackList { - if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { - return true - } - } - } - - return false -} diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go index ce451c0e2..a8bc25ccf 100644 --- a/api/http/proxy/volumes.go +++ b/api/http/proxy/volumes.go @@ -10,6 +10,7 @@ const ( // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") volumeIdentifier = "Name" + volumeLabelForStackIdentifier = "com.docker.stack.namespace" ) // volumeListOperation extracts the response as a JSON object, loop through the volume array @@ -31,7 +32,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo if executor.operationContext.isAdmin { volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls) } else { - volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) + volumeData, err = filterVolumeList(volumeData, executor.operationContext) } if err != nil { return err @@ -45,7 +46,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo } // volumeInspectOperation extracts the response as a JSON object, verify that the user -// has access to the volume based on resource control and either rewrite an access denied response +// has access to the volume based on any existing resource control and either rewrite an access denied response // or a decorated volume. func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { // VolumeInspect response is a JSON object @@ -58,16 +59,85 @@ func volumeInspectOperation(request *http.Request, response *http.Response, exec if responseObject[volumeIdentifier] == nil { return ErrDockerVolumeIdentifierNotFound } - volumeID := responseObject[volumeIdentifier].(string) - resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls) - if resourceControl != nil { - if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { - responseObject = decorateObject(responseObject, resourceControl) - } else { - return rewriteAccessDeniedResponse(response) - } + volumeID := responseObject[volumeIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, volumeID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + volumeLabels := extractVolumeLabelsFromVolumeInspectObject(responseObject) + responseObject, access = applyResourceAccessControlFromLabel(volumeLabels, responseObject, volumeLabelForStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) } return rewriteResponse(response, responseObject, http.StatusOK) } + +// extractVolumeLabelsFromVolumeInspectObject retrieve the Labels of the volume if present. +// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect +func extractVolumeLabelsFromVolumeInspectObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + return extractJSONField(responseObject, "Labels") +} + +// extractVolumeLabelsFromVolumeListObject retrieve the Labels of the volume if present. +// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func extractVolumeLabelsFromVolumeListObject(responseObject map[string]interface{}) map[string]interface{} { + // Labels are stored under Labels + return extractJSONField(responseObject, "Labels") +} + +// decorateVolumeList loops through all volumes and decorates any volume with an existing resource control. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + volumeObject = decorateResourceWithAccessControl(volumeObject, volumeID, resourceControls) + + volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject) + volumeObject = decorateResourceWithAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, resourceControls) + + decoratedVolumeData = append(decoratedVolumeData, volumeObject) + } + + return decoratedVolumeData, nil +} + +// filterVolumeList loops through all volumes and filters public volumes (no associated resource control) +// as well as authorized volumes (access granted to the user based on existing resource control). +// Authorized volumes are decorated during the process. +// Resource controls checks are based on: resource identifier, stack identifier (from label). +// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func filterVolumeList(volumeData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredVolumeData := make([]interface{}, 0) + + for _, volume := range volumeData { + volumeObject := volume.(map[string]interface{}) + if volumeObject[volumeIdentifier] == nil { + return nil, ErrDockerVolumeIdentifierNotFound + } + + volumeID := volumeObject[volumeIdentifier].(string) + volumeObject, access := applyResourceAccessControl(volumeObject, volumeID, context) + if access { + volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject) + volumeObject, access = applyResourceAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, context) + if access { + filteredVolumeData = append(filteredVolumeData, volumeObject) + } + } + } + + return filteredVolumeData, nil +} diff --git a/api/http/server.go b/api/http/server.go index 36344f764..e3f31e8d1 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -27,7 +27,10 @@ type Server struct { FileService portainer.FileService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService + StackService portainer.StackService + StackManager portainer.StackManager LDAPService portainer.LDAPService + GitService portainer.GitService Handler *handler.Handler SSL bool SSLCert string @@ -39,6 +42,7 @@ func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) + var fileHandler = handler.NewFileHandler(server.AssetsPath) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService @@ -82,7 +86,13 @@ func (server *Server) Start() error { resourceHandler.ResourceControlService = server.ResourceControlService var uploadHandler = handler.NewUploadHandler(requestBouncer) uploadHandler.FileService = server.FileService - var fileHandler = handler.NewFileHandler(server.AssetsPath) + var stackHandler = handler.NewStackHandler(requestBouncer) + stackHandler.FileService = server.FileService + stackHandler.StackService = server.StackService + stackHandler.EndpointService = server.EndpointService + stackHandler.ResourceControlService = server.ResourceControlService + stackHandler.StackManager = server.StackManager + stackHandler.GitService = server.GitService server.Handler = &handler.Handler{ AuthHandler: authHandler, @@ -95,6 +105,7 @@ func (server *Server) Start() error { ResourceHandler: resourceHandler, SettingsHandler: settingsHandler, StatusHandler: statusHandler, + StackHandler: stackHandler, TemplatesHandler: templatesHandler, DockerHandler: dockerHandler, WebSocketHandler: websocketHandler, diff --git a/api/portainer.go b/api/portainer.go index 949731dea..b1cfac79a 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -128,6 +128,18 @@ type ( Role UserRole } + // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier). + StackID string + + // Stack represents a Docker stack created via docker stack deploy. + Stack struct { + ID StackID `json:"Id"` + Name string `json:"Name"` + EntryPoint string `json:"EntryPoint"` + SwarmID string `json:"SwarmId"` + ProjectPath string + } + // RegistryID represents a registry identifier. RegistryID int @@ -193,7 +205,7 @@ type ( AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` } - // ResourceControlType represents the type of resource associated to the resource control (volume, container, service). + // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...). ResourceControlType int // UserResourceAccess represents the level of control on a resource for a specific user. @@ -286,6 +298,16 @@ type ( DeleteRegistry(ID RegistryID) error } + // StackService represents a service for managing stack data. + StackService interface { + Stack(ID StackID) (*Stack, error) + Stacks() ([]Stack, error) + StacksBySwarmID(ID string) ([]Stack, error) + CreateStack(stack *Stack) error + UpdateStack(ID StackID, stack *Stack) error + DeleteStack(ID StackID) error + } + // DockerHubService represents a service for managing the DockerHub object. DockerHubService interface { DockerHub() (*DockerHub, error) @@ -328,10 +350,20 @@ type ( // FileService represents a service for managing files. FileService interface { + GetFileContent(filePath string) (string, error) + RemoveDirectory(directoryPath string) error StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFiles(folder string) error + GetStackProjectPath(stackIdentifier string) string + StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error) + StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) + } + + // GitService represents a service for managing Git. + GitService interface { + CloneRepository(url, destination string) error } // EndpointWatcher represents a service to synchronize the endpoints via an external source. @@ -344,6 +376,12 @@ type ( AuthenticateUser(username, password string, settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error } + + // StackManager represents a service to manage stacks. + StackManager interface { + Deploy(stack *Stack, endpoint *Endpoint) error + Remove(stack *Stack, endpoint *Endpoint) error + } ) const ( @@ -406,4 +444,6 @@ const ( NetworkResourceControl // SecretResourceControl represents a resource control associated to a Docker secret SecretResourceControl + // StackResourceControl represents a resource control associated to a stack composed of Docker services + StackResourceControl ) diff --git a/app/__module.js b/app/__module.js index 869198ab0..843fe2239 100644 --- a/app/__module.js +++ b/app/__module.js @@ -28,6 +28,7 @@ angular.module('portainer', [ 'createSecret', 'createService', 'createVolume', + 'createStack', 'engine', 'endpoint', 'endpointAccess', @@ -51,6 +52,8 @@ angular.module('portainer', [ 'settings', 'settingsAuthentication', 'sidebar', + 'stack', + 'stacks', 'swarm', 'swarmVisualizer', 'task', diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 9ad0faf61..59f783530 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -49,52 +49,59 @@ - + State - + Name - - - + + + - + + Stack + + + + + + Image - + IP Address - + Host IP - + Published Ports - + Ownership @@ -111,6 +118,7 @@ {{ container|swarmcontainername|truncate: truncate_size}} {{ container|containername|truncate: truncate_size}} + {{ container.StackName ? container.StackName : '-' }} {{ container.Image | hideshasum }} {{ container.IP ? container.IP : '-' }} {{ container.hostIP }} diff --git a/app/components/createStack/createStackController.js b/app/components/createStack/createStackController.js new file mode 100644 index 000000000..840b81e8c --- /dev/null +++ b/app/components/createStack/createStackController.js @@ -0,0 +1,119 @@ +angular.module('createStack', []) +.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', +function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) { + + // Store the editor content when switching builder methods + var editorContent = ''; + var editorEnabled = true; + + $scope.formValues = { + Name: '', + StackFileContent: '# Define or paste the content of your docker-compose file here', + StackFile: null, + RepositoryURL: '', + RepositoryPath: 'docker-compose.yml', + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + Method: 'editor', + formValidationError: '' + }; + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + function createStack(name) { + var method = $scope.state.Method; + + if (method === 'editor') { + // The codemirror editor does not work with ng-model so we need to retrieve + // the value directly from the editor. + var stackFileContent = $scope.editor.getValue(); + + return StackService.createStackFromFileContent(name, stackFileContent); + } else if (method === 'upload') { + var stackFile = $scope.formValues.StackFile; + return StackService.createStackFromFileUpload(name, stackFile); + } else if (method === 'repository') { + var gitRepository = $scope.formValues.RepositoryURL; + var pathInRepository = $scope.formValues.RepositoryPath; + return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository); + } + } + + $scope.deployStack = function () { + $('#createResourceSpinner').show(); + + var name = $scope.formValues.Name; + + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + var userId = userDetails.ID; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + createStack(name) + .then(function success(data) { + Notifications.success('Stack successfully deployed'); + }) + .catch(function error(err) { + Notifications.warning('Deployment error', err.err.data.err); + }) + .then(function success(data) { + return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []); + }) + .then(function success() { + $state.go('stacks'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to apply resource control on the stack'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + + function enableEditor(value) { + $document.ready(function() { + var webEditorElement = $document[0].getElementById('web-editor'); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + if (value) { + $scope.editor.setValue(value); + } + } + }); + } + + $scope.toggleEditor = function() { + if (!editorEnabled) { + enableEditor(editorContent); + editorEnabled = true; + } + }; + + $scope.saveEditorContent = function() { + editorContent = $scope.editor.getValue(); + editorEnabled = false; + }; + + function initView() { + enableEditor(); + } + + initView(); +}]); diff --git a/app/components/createStack/createstack.html b/app/components/createStack/createstack.html new file mode 100644 index 000000000..4845e58e3 --- /dev/null +++ b/app/components/createStack/createstack.html @@ -0,0 +1,156 @@ + + + + + + Stacks > Add stack + + + +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + This stack will be deployed using the equivalent of the docker stack deploy command. + +
+ +
+ Build method +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+ + You can get more information about Compose file format in the official documentation. + +
+
+
+ +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a Compose file from your computer. + +
+
+
+ + + {{ formValues.StackFile.name }} + + +
+
+
+ + +
+
+ Git repository +
+
+ + You can use the URL of a public git repository. + +
+
+ +
+ +
+
+
+ + Indicate the path to the Compose file from the root of your repository. + +
+
+ +
+ +
+
+
+ + + +
+ Actions +
+
+
+ + Cancel + + {{ state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index d3b1426c5..ebc2f7501 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -85,6 +85,32 @@
+ +
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index c9598209c..4f9d83094 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -1,6 +1,6 @@ angular.module('dashboard', []) -.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'Notifications', -function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, Notifications) { +.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications', +function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications) { $scope.containerData = { total: 0 @@ -15,6 +15,9 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System total: 0 }; + $scope.serviceCount = 0; + $scope.stackCount = 0; + function prepareContainerData(d) { var running = 0; var stopped = 0; @@ -63,18 +66,25 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System function initView() { $('#loadingViewSpinner').show(); + + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + $q.all([ Container.query({all: 1}).$promise, Image.query({}).$promise, Volume.query({}).$promise, Network.query({}).$promise, - SystemService.info() + SystemService.info(), + endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [], + endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : [] ]).then(function (d) { prepareContainerData(d[0]); prepareImageData(d[1]); prepareVolumeData(d[2]); prepareNetworkData(d[3]); prepareInfoData(d[4]); + $scope.serviceCount = d[5].length; + $scope.stackCount = d[6].length; $('#loadingViewSpinner').hide(); }, function(e) { $('#loadingViewSpinner').hide(); diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index 4773aa779..59c64627d 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -48,10 +48,10 @@ - - Id - - + + Stack + + @@ -101,8 +101,8 @@ - {{ network.Name|truncate:40}} - {{ network.Id|truncate:20 }} + {{ network.Name | truncate:40 }} + {{ network.StackName ? network.StackName : '-' }} {{ network.Scope }} {{ network.Driver }} {{ network.IPAM.Driver }} diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index cf54ae214..9ff2cb6e2 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -321,7 +321,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, originalService = angular.copy(service); return $q.all({ - tasks: TaskService.serviceTasks(service.Name), + tasks: TaskService.tasks({ service: [service.Name] }), nodes: NodeService.nodes(), secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] }); diff --git a/app/components/services/services.html b/app/components/services/services.html index 3b373937a..2ff52b79a 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -38,42 +38,49 @@ - + Name - + + Stack + + + + + + Image - + Scheduling mode - + Published Ports - + Updated at - + Ownership @@ -84,6 +91,7 @@ {{ service.Name }} + {{ service.StackName ? service.StackName : '-' }} {{ service.Image | hideshasum }} {{ service.Mode }} diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index b85bb2c64..da646ae77 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -25,6 +25,9 @@ LinuxServer.io
+ diff --git a/app/components/stack/stack.html b/app/components/stack/stack.html new file mode 100644 index 000000000..8795ce307 --- /dev/null +++ b/app/components/stack/stack.html @@ -0,0 +1,54 @@ + + + + + + + + + Stacks > {{ stack.Name }} + + + + + + + + + + + +
+
+ + + +
+
+ + You can get more information about Compose file format in the official documentation. + +
+
+
+ +
+
+
+ Actions +
+
+
+ + +
+
+
+
+
+
+
diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js new file mode 100644 index 000000000..b483c1cc7 --- /dev/null +++ b/app/components/stack/stackController.js @@ -0,0 +1,78 @@ +angular.module('stack', []) +.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', +function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) { + + $scope.deployStack = function () { + $('#createResourceSpinner').show(); + + // The codemirror editor does not work with ng-model so we need to retrieve + // the value directly from the editor. + var stackFile = $scope.editor.getValue(); + + StackService.updateStack($scope.stack.Id, stackFile) + .then(function success(data) { + Notifications.success('Stack successfully deployed'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create stack'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + var stackId = $stateParams.id; + + StackService.stack(stackId) + .then(function success(data) { + var stack = data; + $scope.stack = stack; + + var serviceFilters = { + label: ['com.docker.stack.namespace=' + stack.Name] + }; + + return $q.all({ + stackFile: StackService.getStackFile(stackId), + services: ServiceService.services(serviceFilters), + tasks: TaskService.tasks(serviceFilters), + nodes: NodeService.nodes() + }); + }) + .then(function success(data) { + $scope.stackFileContent = data.stackFile; + + $document.ready(function() { + var webEditorElement = $document[0].getElementById('web-editor'); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + } + }); + + $scope.nodes = data.nodes; + + var services = data.services; + + var tasks = data.tasks; + $scope.tasks = tasks; + + for (var i = 0; i < services.length; i++) { + var service = services[i]; + ServiceHelper.associateTasksToService(service, tasks); + } + + $scope.services = services; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tasks details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/stacks/stacks.html b/app/components/stacks/stacks.html new file mode 100644 index 000000000..8d585eea8 --- /dev/null +++ b/app/components/stacks/stacks.html @@ -0,0 +1,120 @@ + + + + + + + + Stacks + + +
+
+ + + +
+
+ + Stacks marked with the icon are external stacks that were created outside of Portainer. You'll not be able to execute any actions against these stacks. + +
+
+ Filters +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ + Add stack +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Ownership + + + +
+ + {{ stack.Name }} + + + {{ stack.Name }} + + + + + {{ stack.ResourceControl.Ownership ? stack.ResourceControl.Ownership : stack.ResourceControl.Ownership = 'public' }} + +
Loading...
No stacks available.
+
+ +
+
+
+ +
+
diff --git a/app/components/stacks/stacksController.js b/app/components/stacks/stacksController.js new file mode 100644 index 000000000..352891acd --- /dev/null +++ b/app/components/stacks/stacksController.js @@ -0,0 +1,103 @@ +angular.module('stacks', []) +.controller('StacksController', ['$scope', 'Notifications', 'Pagination', 'StackService', 'ModalService', +function ($scope, Notifications, Pagination, StackService, ModalService) { + $scope.state = {}; + $scope.state.selectedItemCount = 0; + $scope.state.pagination_count = Pagination.getPaginationCount('stacks'); + $scope.sortType = 'Name'; + $scope.sortReverse = false; + $scope.state.DisplayInformationPanel = false; + $scope.state.DisplayExternalStacks = true; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('stacks', $scope.state.pagination_count); + }; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredStacks, function (stack) { + if (stack.Id && stack.Checked !== allSelected) { + stack.Checked = allSelected; + $scope.selectItem(stack); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.removeAction = function () { + ModalService.confirmDeletion( + 'Do you want to remove the selected stack(s)? Associated services will be removed as well.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedStacks(); + } + ); + }; + + function deleteSelectedStacks() { + $('#loadingViewSpinner').show(); + var counter = 0; + + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadingViewSpinner').hide(); + } + }; + + angular.forEach($scope.stacks, function (stack) { + if (stack.Checked) { + counter = counter + 1; + StackService.remove(stack) + .then(function success() { + Notifications.success('Stack deleted', stack.Name); + var index = $scope.stacks.indexOf(stack); + $scope.stacks.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); + }) + .finally(function final() { + complete(); + }); + } + }); + } + + function initView() { + $('#loadingViewSpinner').show(); + + StackService.stacks(true) + .then(function success(data) { + var stacks = data; + for (var i = 0; i < stacks.length; i++) { + var stack = stacks[i]; + if (stack.External) { + $scope.state.DisplayInformationPanel = true; + break; + } + } + $scope.stacks = stacks; + }) + .catch(function error(err) { + $scope.stacks = []; + Notifications.error('Failure', err, 'Unable to retrieve stacks'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index c90e40945..fb9dd94b0 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -57,6 +57,13 @@ + + + Stack + + + + Driver @@ -87,6 +94,7 @@ {{ volume.Id|truncate:25 }} Unused + {{ volume.StackName ? volume.StackName : '-' }} {{ volume.Driver }} {{ volume.Mountpoint | truncatelr }} diff --git a/app/directives/accessControlPanel/porAccessControlPanel.html b/app/directives/accessControlPanel/porAccessControlPanel.html index 221b18515..e9fd62cd2 100644 --- a/app/directives/accessControlPanel/porAccessControlPanel.html +++ b/app/directives/accessControlPanel/porAccessControlPanel.html @@ -37,6 +37,13 @@ + + + + Access control on this resource is inherited from the following stack: {{ $ctrl.resourceControl.ResourceId }} + + + Authorized users @@ -54,7 +61,11 @@ - + Change ownership diff --git a/app/directives/serviceList/por-service-list.js b/app/directives/serviceList/por-service-list.js new file mode 100644 index 000000000..4a33ba96e --- /dev/null +++ b/app/directives/serviceList/por-service-list.js @@ -0,0 +1,8 @@ +angular.module('portainer').component('porServiceList', { + templateUrl: 'app/directives/serviceList/porServiceList.html', + controller: 'porServiceListController', + bindings: { + 'services': '<', + 'nodes': '<' + } +}); diff --git a/app/directives/serviceList/porServiceList.html b/app/directives/serviceList/porServiceList.html new file mode 100644 index 000000000..e87f74d15 --- /dev/null +++ b/app/directives/serviceList/porServiceList.html @@ -0,0 +1,98 @@ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Image + + + + + + Scheduling mode + + + + + + Published Ports + + + + + + Updated at + + + +
{{ service.Name }}{{ service.Image | hideshasum }} + {{ service.Mode }} + {{ service.Tasks | runningtaskscount }} + / + {{ service.Mode === 'replicated' ? service.Replicas : ($ctrl.nodes | availablenodecount) }} + + + {{ p.PublishedPort }}:{{ p.TargetPort }} + + - + + {{ service.UpdatedAt|getisodate }} +
Loading...
No services available.
+
+ +
+
+
+ +
+
diff --git a/app/directives/serviceList/porServiceList.js b/app/directives/serviceList/porServiceList.js new file mode 100644 index 000000000..b755ef70c --- /dev/null +++ b/app/directives/serviceList/porServiceList.js @@ -0,0 +1,21 @@ +angular.module('portainer') +.controller('porServiceListController', ['EndpointProvider', 'Pagination', +function (EndpointProvider, Pagination) { + var ctrl = this; + ctrl.state = { + pagination_count: Pagination.getPaginationCount('services_list'), + publicURL: EndpointProvider.endpointPublicURL() + }; + ctrl.sortType = 'Name'; + ctrl.sortReverse = false; + + ctrl.order = function(sortType) { + ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false; + ctrl.sortType = sortType; + }; + + ctrl.changePaginationCount = function() { + Pagination.setPaginationCount('services_list', ctrl.state.pagination_count); + }; + +}]); diff --git a/app/directives/taskList/por-task-list.js b/app/directives/taskList/por-task-list.js new file mode 100644 index 000000000..6ed1483d3 --- /dev/null +++ b/app/directives/taskList/por-task-list.js @@ -0,0 +1,8 @@ +angular.module('portainer').component('porTaskList', { + templateUrl: 'app/directives/taskList/porTaskList.html', + controller: 'porTaskListController', + bindings: { + 'tasks': '<', + 'nodes': '<' + } +}); diff --git a/app/directives/taskList/porTaskList.html b/app/directives/taskList/porTaskList.html new file mode 100644 index 000000000..c2c75d7da --- /dev/null +++ b/app/directives/taskList/porTaskList.html @@ -0,0 +1,78 @@ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Id + + Status + + + + + + Slot + + + + + + Node + + + + + + Last update + + + +
{{ task.Id }}{{ task.Status.State }}{{ task.Slot ? task.Slot : '-' }}{{ task.NodeId | tasknodename: $ctrl.nodes }}{{ task.Updated | getisodate }}
Loading...
No tasks available.
+
+ +
+
+
+
+
diff --git a/app/directives/taskList/porTaskList.js b/app/directives/taskList/porTaskList.js new file mode 100644 index 000000000..e53f546d0 --- /dev/null +++ b/app/directives/taskList/porTaskList.js @@ -0,0 +1,19 @@ +angular.module('portainer') +.controller('porTaskListController', ['Pagination', +function (Pagination) { + var ctrl = this; + ctrl.state = { + pagination_count: Pagination.getPaginationCount('tasks_list') + }; + ctrl.sortType = 'Updated'; + ctrl.sortReverse = true; + + ctrl.order = function(sortType) { + ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false; + ctrl.sortType = sortType; + }; + + ctrl.changePaginationCount = function() { + Pagination.setPaginationCount('tasks_list', ctrl.state.pagination_count); + }; +}]); diff --git a/app/filters/filters.js b/app/filters/filters.js index e1d61bd95..efc463d23 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -75,7 +75,7 @@ angular.module('portainer.filters', []) return 'warning'; } else if (includeString(status, ['created'])) { return 'info'; - } else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) { + } else if (includeString(status, ['stopped', 'unhealthy', 'dead', 'exited'])) { return 'danger'; } return 'success'; @@ -298,6 +298,32 @@ angular.module('portainer.filters', []) } }; }) +.filter('availablenodecount', function () { + 'use strict'; + return function (nodes) { + var availableNodes = 0; + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node.Availability === 'active' && node.Status === 'ready') { + availableNodes++; + } + } + return availableNodes; + }; +}) +.filter('runningtaskscount', function () { + 'use strict'; + return function (tasks) { + var runningTasks = 0; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + if (task.Status.State === 'running') { + runningTasks++; + } + } + return runningTasks; + }; +}) .filter('tasknodename', function () { 'use strict'; return function (nodeId, nodes) { diff --git a/app/helpers/serviceHelper.js b/app/helpers/serviceHelper.js index ec3411421..00d063278 100644 --- a/app/helpers/serviceHelper.js +++ b/app/helpers/serviceHelper.js @@ -1,123 +1,145 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() { 'use strict'; - return { - serviceToConfig: function(service) { - return { - Name: service.Spec.Name, - Labels: service.Spec.Labels, - TaskTemplate: service.Spec.TaskTemplate, - Mode: service.Spec.Mode, - UpdateConfig: service.Spec.UpdateConfig, - Networks: service.Spec.Networks, - EndpointSpec: service.Spec.EndpointSpec - }; - }, - translateKeyValueToPlacementPreferences: function(keyValuePreferences) { - if (keyValuePreferences) { - var preferences = []; - keyValuePreferences.forEach(function(preference) { - if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') { - switch (preference.strategy.toLowerCase()) { - case 'spread': - preferences.push({ - 'Spread': { - 'SpreadDescriptor': preference.value - } - }); - break; - } + + var helper = {}; + + helper.associateTasksToService = function(service, tasks) { + service.Tasks = []; + var otherServicesTasks = []; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + if (task.ServiceId === service.Id) { + service.Tasks.push(task); + } else { + otherServicesTasks.push(task); + } + } + tasks = otherServicesTasks; + }; + + helper.serviceToConfig = function(service) { + return { + Name: service.Spec.Name, + Labels: service.Spec.Labels, + TaskTemplate: service.Spec.TaskTemplate, + Mode: service.Spec.Mode, + UpdateConfig: service.Spec.UpdateConfig, + Networks: service.Spec.Networks, + EndpointSpec: service.Spec.EndpointSpec + }; + }; + + helper.translateKeyValueToPlacementPreferences = function(keyValuePreferences) { + if (keyValuePreferences) { + var preferences = []; + keyValuePreferences.forEach(function(preference) { + if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') { + switch (preference.strategy.toLowerCase()) { + case 'spread': + preferences.push({ + 'Spread': { + 'SpreadDescriptor': preference.value + } + }); + break; } - }); - return preferences; - } - return []; - }, - translateKeyValueToPlacementConstraints: function(keyValueConstraints) { - if (keyValueConstraints) { - var constraints = []; - keyValueConstraints.forEach(function(constraint) { - if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') { - constraints.push(constraint.key + constraint.operator + constraint.value); - } - }); - return constraints; - } - return []; - }, - translateEnvironmentVariables: function(env) { - if (env) { - var variables = []; - env.forEach(function(variable) { - var idx = variable.indexOf('='); - var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)]; - var originalValue = (keyValue.length > 1) ? keyValue[1] : ''; - variables.push({ - key: keyValue[0], - value: originalValue, - originalKey: keyValue[0], - originalValue: originalValue, - added: true - }); - }); - return variables; - } - return []; - }, - translateEnvironmentVariablesToEnv: function(env) { - if (env) { - var variables = []; - env.forEach(function(variable) { - if (variable.key && variable.key !== '') { - variables.push(variable.key + '=' + variable.value); - } - }); - return variables; - } - return []; - }, - translatePreferencesToKeyValue: function(preferences) { - if (preferences) { - var keyValuePreferences = []; - preferences.forEach(function(preference) { - if (preference.Spread) { - keyValuePreferences.push({ - strategy: 'Spread', - value: preference.Spread.SpreadDescriptor - }); - } - }); - return keyValuePreferences; - } - return []; - }, - translateConstraintsToKeyValue: function(constraints) { - function getOperator(constraint) { - var indexEquals = constraint.indexOf('=='); - if (indexEquals >= 0) { - return [indexEquals, '==']; } - return [constraint.indexOf('!='), '!=']; - } - if (constraints) { - var keyValueConstraints = []; - constraints.forEach(function(constraint) { - var operatorIndices = getOperator(constraint); + }); + return preferences; + } + return []; + }; - var key = constraint.slice(0, operatorIndices[0]); - var operator = operatorIndices[1]; - var value = constraint.slice(operatorIndices[0] + 2); + helper.translateKeyValueToPlacementConstraints = function(keyValueConstraints) { + if (keyValueConstraints) { + var constraints = []; + keyValueConstraints.forEach(function(constraint) { + if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') { + constraints.push(constraint.key + constraint.operator + constraint.value); + } + }); + return constraints; + } + return []; + }; - keyValueConstraints.push({ - key: key, - value: value, - operator: operator, - originalKey: key, - originalValue: value - }); + helper.translateEnvironmentVariables = function(env) { + if (env) { + var variables = []; + env.forEach(function(variable) { + var idx = variable.indexOf('='); + var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)]; + var originalValue = (keyValue.length > 1) ? keyValue[1] : ''; + variables.push({ + key: keyValue[0], + value: originalValue, + originalKey: keyValue[0], + originalValue: originalValue, + added: true }); - return keyValueConstraints; + }); + return variables; + } + return []; + }; + + helper.translateEnvironmentVariablesToEnv = function(env) { + if (env) { + var variables = []; + env.forEach(function(variable) { + if (variable.key && variable.key !== '') { + variables.push(variable.key + '=' + variable.value); + } + }); + return variables; + } + return []; + }; + + helper.translatePreferencesToKeyValue = function(preferences) { + if (preferences) { + var keyValuePreferences = []; + preferences.forEach(function(preference) { + if (preference.Spread) { + keyValuePreferences.push({ + strategy: 'Spread', + value: preference.Spread.SpreadDescriptor + }); + } + }); + return keyValuePreferences; + } + return []; + }; + + helper.translateConstraintsToKeyValue = function(constraints) { + function getOperator(constraint) { + var indexEquals = constraint.indexOf('=='); + if (indexEquals >= 0) { + return [indexEquals, '==']; } - return []; + return [constraint.indexOf('!='), '!=']; + } + if (constraints) { + var keyValueConstraints = []; + constraints.forEach(function(constraint) { + var operatorIndices = getOperator(constraint); + + var key = constraint.slice(0, operatorIndices[0]); + var operator = operatorIndices[1]; + var value = constraint.slice(operatorIndices[0] + 2); + + keyValueConstraints.push({ + key: key, + value: value, + operator: operator, + originalKey: key, + originalValue: value + }); + }); + return keyValueConstraints; } }; + + return helper; }]); diff --git a/app/helpers/stackHelper.js b/app/helpers/stackHelper.js new file mode 100644 index 000000000..8701d9d4f --- /dev/null +++ b/app/helpers/stackHelper.js @@ -0,0 +1,21 @@ +angular.module('portainer.helpers') +.factory('StackHelper', [function StackHelperFactory() { + 'use strict'; + var helper = {}; + + helper.getExternalStackNamesFromServices = function(services) { + var stackNames = []; + + for (var i = 0; i < services.length; i++) { + var service = services[i]; + if (!service.Labels || !service.Labels['com.docker.stack.namespace']) continue; + + var stackName = service.Labels['com.docker.stack.namespace']; + stackNames.push(stackName); + } + + return _.uniq(stackNames); + }; + + return helper; +}]); diff --git a/app/models/api/stack.js b/app/models/api/stack.js new file mode 100644 index 000000000..3d4645913 --- /dev/null +++ b/app/models/api/stack.js @@ -0,0 +1,9 @@ +function StackViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Checked = false; + if (data.ResourceControl && data.ResourceControl.Id !== 0) { + this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); + } + this.External = data.External; +} diff --git a/app/models/docker/container.js b/app/models/docker/container.js index 552963b22..8c2d112f5 100644 --- a/app/models/docker/container.js +++ b/app/models/docker/container.js @@ -8,9 +8,15 @@ function ContainerViewModel(data) { this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; } this.Image = data.Image; + this.ImageID = data.ImageID; this.Command = data.Command; this.Checked = false; this.Labels = data.Labels; + if (this.Labels && this.Labels['com.docker.compose.project']) { + this.StackName = this.Labels['com.docker.compose.project']; + } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } this.Mounts = data.Mounts; this.Ports = []; diff --git a/app/models/docker/network.js b/app/models/docker/network.js index 820b35ab6..baa5ab984 100644 --- a/app/models/docker/network.js +++ b/app/models/docker/network.js @@ -8,6 +8,13 @@ function NetworkViewModel(data) { this.Containers = data.Containers; this.Options = data.Options; + this.Labels = data.Labels; + if (this.Labels && this.Labels['com.docker.compose.project']) { + this.StackName = this.Labels['com.docker.compose.project']; + } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } + if (data.Portainer) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); diff --git a/app/models/docker/service.js b/app/models/docker/service.js index 3c58cd9e3..28d8609ba 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -1,6 +1,7 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Model = data; this.Id = data.ID; + this.Tasks = []; this.Name = data.Spec.Name; this.CreatedAt = data.CreatedAt; this.UpdatedAt = data.UpdatedAt; @@ -44,6 +45,9 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : []; this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : []; this.Labels = data.Spec.Labels; + if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } var containerSpec = data.Spec.TaskTemplate.ContainerSpec; if (containerSpec) { diff --git a/app/models/docker/swarm.js b/app/models/docker/swarm.js new file mode 100644 index 000000000..9ed805335 --- /dev/null +++ b/app/models/docker/swarm.js @@ -0,0 +1,3 @@ +function SwarmViewModel(data) { + this.Id = data.ID; +} diff --git a/app/models/docker/volume.js b/app/models/docker/volume.js index fc6dbd848..5e2701b70 100644 --- a/app/models/docker/volume.js +++ b/app/models/docker/volume.js @@ -3,6 +3,11 @@ function VolumeViewModel(data) { this.Driver = data.Driver; this.Options = data.Options; this.Labels = data.Labels; + if (this.Labels && this.Labels['com.docker.compose.project']) { + this.StackName = this.Labels['com.docker.compose.project']; + } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } this.Mountpoint = data.Mountpoint; if (data.Portainer) { diff --git a/app/rest/api/stack.js b/app/rest/api/stack.js new file mode 100644 index 000000000..6512c5128 --- /dev/null +++ b/app/rest/api/stack.js @@ -0,0 +1,15 @@ +angular.module('portainer.rest') +.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/stacks/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { + get: { method: 'GET', params: { id: '@id' } }, + query: { method: 'GET', isArray: true }, + create: { method: 'POST' }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + getStackFile: { method: 'GET', params: { id : '@id', action: 'stackfile' } } + }); +}]); diff --git a/app/rest/docker/service.js b/app/rest/docker/service.js index e8ca55962..466ded8e8 100644 --- a/app/rest/docker/service.js +++ b/app/rest/docker/service.js @@ -6,7 +6,7 @@ angular.module('portainer.rest') }, { get: { method: 'GET', params: {id: '@id'} }, - query: { method: 'GET', isArray: true }, + query: { method: 'GET', isArray: true, params: {filters: '@filters'} }, create: { method: 'POST', params: {action: 'create'}, headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader } diff --git a/app/routes.js b/app/routes.js index 15db71679..0232c3d6d 100644 --- a/app/routes.js +++ b/app/routes.js @@ -661,5 +661,44 @@ function configureRoutes($stateProvider) { controller: 'SidebarController' } } + }) + .state('actions.create.stack', { + url: '/stack', + views: { + 'content@': { + templateUrl: 'app/components/createStack/createstack.html', + controller: 'CreateStackController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('stacks', { + url: '/stacks/', + views: { + 'content@': { + templateUrl: 'app/components/stacks/stacks.html', + controller: 'StacksController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('stack', { + url: '^/stacks/:id/', + views: { + 'content@': { + templateUrl: 'app/components/stack/stack.html', + controller: 'StackController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } }); } diff --git a/app/services/api/stackService.js b/app/services/api/stackService.js new file mode 100644 index 000000000..6ab6e7f8a --- /dev/null +++ b/app/services/api/stackService.js @@ -0,0 +1,162 @@ +angular.module('portainer.services') +.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'SwarmService', +function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, SwarmService) { + 'use strict'; + var service = {}; + + service.stack = function(id) { + var deferred = $q.defer(); + + Stack.get({ id: id }).$promise + .then(function success(data) { + var stack = new StackViewModel(data); + deferred.resolve(stack); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack details', err: err }); + }); + + return deferred.promise; + }; + + service.getStackFile = function(id) { + var deferred = $q.defer(); + + Stack.getStackFile({ id: id }).$promise + .then(function success(data) { + deferred.resolve(data.StackFileContent); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack content', err: err }); + }); + + return deferred.promise; + }; + + service.externalStacks = function() { + var deferred = $q.defer(); + + ServiceService.services() + .then(function success(data) { + var services = data; + var stackNames = StackHelper.getExternalStackNamesFromServices(services); + var stacks = stackNames.map(function (item) { + return new StackViewModel({ Name: item, External: true }); + }); + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.stacks = function(includeExternalStacks) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + + return $q.all({ + stacks: Stack.query({ swarmId: swarm.Id }).$promise, + externalStacks: includeExternalStacks ? service.externalStacks() : [] + }); + }) + .then(function success(data) { + var stacks = data.stacks.map(function (item) { + item.External = false; + return new StackViewModel(item); + }); + var externalStacks = data.externalStacks; + + var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; }); + deferred.resolve(result); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(stack) { + var deferred = $q.defer(); + + Stack.remove({ id: stack.Id }).$promise + .then(function success(data) { + if (stack.ResourceControl && stack.ResourceControl.Id) { + return ResourceControlService.deleteResourceControl(stack.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createStackFromFileContent = function(name, stackFileContent) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createStackFromGitRepository = function(name, gitRepository, pathInRepository) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createStackFromFileUpload = function(name, stackFile) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + return FileUploadService.createStack(name, swarm.Id, stackFile); + }) + .then(function success(data) { + deferred.resolve(data.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.updateStack = function(id, stackFile) { + return Stack.update({ id: id, StackFileContent: stackFile }).$promise; + }; + + return service; +}]); diff --git a/app/services/codeMirror.js b/app/services/codeMirror.js new file mode 100644 index 000000000..b5807f87e --- /dev/null +++ b/app/services/codeMirror.js @@ -0,0 +1,21 @@ +angular.module('portainer.services') +.factory('CodeMirrorService', function CodeMirrorService() { + 'use strict'; + + var codeMirrorOptions = { + lineNumbers: true, + mode: 'text/x-yaml', + gutters: ['CodeMirror-lint-markers'], + lint: true + }; + + var service = {}; + + service.applyCodeMirrorOnElement = function(element) { + var cm = CodeMirror.fromTextArea(element, codeMirrorOptions); + cm.setSize('100%', 500); + return cm; + }; + + return service; +}); diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index c9f302d56..21bd1c295 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -18,16 +18,20 @@ angular.module('portainer.services') return deferred.promise; }; - service.containers = function(all) { + service.containers = function(all, filters) { var deferred = $q.defer(); - Container.query({ all: all }).$promise + + Container.query({ all: all, filters: filters ? filters : {} }).$promise .then(function success(data) { - var containers = data; + var containers = data.map(function (item) { + return new ContainerViewModel(item); + }); deferred.resolve(containers); }) .catch(function error(err) { deferred.reject({ msg: 'Unable to retrieve containers', err: err }); }); + return deferred.promise; }; diff --git a/app/services/docker/serviceService.js b/app/services/docker/serviceService.js index 0020c3412..65ad05709 100644 --- a/app/services/docker/serviceService.js +++ b/app/services/docker/serviceService.js @@ -1,12 +1,12 @@ angular.module('portainer.services') -.factory('ServiceService', ['$q', 'Service', 'ResourceControlService', function ServiceServiceFactory($q, Service, ResourceControlService) { +.factory('ServiceService', ['$q', 'Service', 'ServiceHelper', 'TaskService', 'ResourceControlService', function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService) { 'use strict'; var service = {}; - service.services = function() { + service.services = function(filters) { var deferred = $q.defer(); - Service.query().$promise + Service.query({ filters: filters ? filters : {} }).$promise .then(function success(data) { var services = data.map(function (item) { return new ServiceViewModel(item); diff --git a/app/services/docker/swarmService.js b/app/services/docker/swarmService.js new file mode 100644 index 000000000..8a2dc3ff5 --- /dev/null +++ b/app/services/docker/swarmService.js @@ -0,0 +1,22 @@ +angular.module('portainer.services') +.factory('SwarmService', ['$q', 'Swarm', function SwarmServiceFactory($q, Swarm) { + 'use strict'; + var service = {}; + + service.swarm = function() { + var deferred = $q.defer(); + + Swarm.get().$promise + .then(function success(data) { + var swarm = new SwarmViewModel(data); + deferred.resolve(swarm); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Swarm details', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/docker/taskService.js b/app/services/docker/taskService.js index 44dab1922..13babf0aa 100644 --- a/app/services/docker/taskService.js +++ b/app/services/docker/taskService.js @@ -3,23 +3,6 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.tasks = function() { - var deferred = $q.defer(); - - Task.query().$promise - .then(function success(data) { - var tasks = data.map(function (item) { - return new TaskViewModel(item); - }); - deferred.resolve(tasks); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve tasks', err: err }); - }); - - return deferred.promise; - }; - service.task = function(id) { var deferred = $q.defer(); @@ -35,10 +18,10 @@ angular.module('portainer.services') return deferred.promise; }; - service.serviceTasks = function(serviceName) { + service.tasks = function(filters) { var deferred = $q.defer(); - Task.query({ filters: { service: [serviceName] } }).$promise + Task.query({ filters: filters ? filters : {} }).$promise .then(function success(data) { var tasks = data.map(function (item) { return new TaskViewModel(item); @@ -46,7 +29,7 @@ angular.module('portainer.services') deferred.resolve(tasks); }) .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve tasks associated to the service', err: err }); + deferred.reject({ msg: 'Unable to retrieve tasks', err: err }); }); return deferred.promise; diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 8b256ca00..0f3f46643 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('FileUploadService', ['$q', 'Upload', function FileUploadFactory($q, Upload) { +.factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) { 'use strict'; var service = {}; @@ -8,6 +8,11 @@ angular.module('portainer.services') return Upload.upload({ url: url, data: { file: file }}); } + service.createStack = function(stackName, swarmId, file) { + var endpointID = EndpointProvider.endpointID(); + return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } }); + }; + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { var queue = []; diff --git a/app/services/notifications.js b/app/services/notifications.js index 3679cba18..d124f2deb 100644 --- a/app/services/notifications.js +++ b/app/services/notifications.js @@ -7,6 +7,10 @@ angular.module('portainer.services') toastr.success($sanitize(text), $sanitize(title)); }; + service.warning = function(title, text) { + toastr.warning($sanitize(text), $sanitize(title), {timeOut: 6000}); + }; + service.error = function(title, e, fallbackText) { var msg = fallbackText; if (e.data && e.data.message) { @@ -17,13 +21,14 @@ angular.module('portainer.services') msg = e.err.data.message; } else if (e.data && e.data.length > 0 && e.data[0].message) { msg = e.data[0].message; - } else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) { - msg = e.err.data[0].message; - } else if (e.msg) { - msg = e.msg; + } else if (e.err && e.err.data && e.err.data.err) { + msg = e.err.data.err; } else if (e.data && e.data.err) { msg = e.data.err; + } else if (e.msg) { + msg = e.msg; } + if (msg !== 'Invalid JWT token') { toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000}); } diff --git a/bower.json b/bower.json index 536f8bc04..ed30cca55 100644 --- a/bower.json +++ b/bower.json @@ -50,7 +50,9 @@ "xterm.js": "~2.8.1", "chart.js": "~2.6.0", "angularjs-slider": "^6.4.0", - "angular-ui-router": "~1.0.6" + "angular-ui-router": "~1.0.6", + "codemirror": "~5.30.0", + "js-yaml": "~3.10.0" }, "resolutions": { "angular": "1.5.11" diff --git a/build.sh b/build.sh index 3219679e4..77271abe9 100755 --- a/build.sh +++ b/build.sh @@ -41,9 +41,8 @@ else VERSION="$1" if [ `echo "$@" | cut -c1-4` == 'echo' ]; then bash -c "$@"; - else - build_all 'linux-amd64 linux-386 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64' + else + build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64' exit 0 fi fi - diff --git a/build/download_docker_binary.sh b/build/download_docker_binary.sh new file mode 100755 index 000000000..cc9b94b00 --- /dev/null +++ b/build/download_docker_binary.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +PLATFORM=$1 +ARCH=$2 +DOCKER_VERSION=$3 + +DOWNLOAD_FOLDER=".tmp/download" + +rm -rf "${DOWNLOAD_FOLDER}" +mkdir -pv "${DOWNLOAD_FOLDER}" + +if [ "${PLATFORM}" == 'win' ]; then + wget -O "${DOWNLOAD_FOLDER}/docker-binaries.zip" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.zip" + unzip "${DOWNLOAD_FOLDER}/docker-binaries.zip" -d "${DOWNLOAD_FOLDER}" + mv "${DOWNLOAD_FOLDER}/docker/docker.exe" dist/ +else + wget -O "${DOWNLOAD_FOLDER}/docker-binaries.tgz" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.tgz" + tar -xf "${DOWNLOAD_FOLDER}/docker-binaries.tgz" -C "${DOWNLOAD_FOLDER}" + mv "${DOWNLOAD_FOLDER}/docker/docker" dist/ +fi + +exit 0 diff --git a/build/windows/microsoftservercore/Dockerfile b/build/windows/microsoftservercore/Dockerfile deleted file mode 100644 index dfcd8b15a..000000000 --- a/build/windows/microsoftservercore/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM microsoft/windowsservercore - -COPY dist / - -VOLUME C:\\data - -WORKDIR / - -EXPOSE 9000 - -ENTRYPOINT ["/portainer.exe"] diff --git a/gruntfile.js b/gruntfile.js index ba27bb6c9..d4ff5fede 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -34,6 +34,7 @@ module.exports = function (grunt) { 'config:dev', 'clean:app', 'shell:buildBinary:linux:amd64', + 'shell:downloadDockerBinary:linux:amd64', 'vendor:regular', 'html2js', 'useminPrepare:dev', @@ -44,7 +45,7 @@ module.exports = function (grunt) { 'after-copy' ]); grunt.task.registerTask('release', 'release::', function(p, a) { - grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:'+p+':'+a, 'before-copy', 'copy:assets', 'after-copy' ]); + grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:'+p+':'+a, 'shell:downloadDockerBinary:'+p+':'+a, 'before-copy', 'copy:assets', 'after-copy' ]); }); grunt.registerTask('lint', ['eslint']); grunt.registerTask('run-dev', ['build', 'shell:run', 'watch:build']); @@ -53,6 +54,7 @@ module.exports = function (grunt) { // Project configuration. grunt.initConfig({ distdir: 'dist', + shippedDockerVersion: '17.09.0-ce', pkg: grunt.file.readJSON('package.json'), config: { dev: { options: { variables: { 'environment': 'development' }}}, @@ -67,7 +69,7 @@ module.exports = function (grunt) { }, clean: { all: ['<%= distdir %>/*'], - app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*'], + app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*', '!<%= distdir %>/docker*'], tmpl: ['<%= distdir %>/templates'], tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css'] }, @@ -170,19 +172,33 @@ module.exports = function (grunt) { shell: { buildBinary: { command: function (p, a) { - var binfile = 'dist/portainer-'+p+'-'+a; - if (grunt.file.isFile( ( p === 'windows' ) ? binfile+'.exe' : binfile )) { - return 'echo \'BinaryExists\''; - } else { - return 'build/build_in_container.sh ' + p + ' ' + a; - } - } + var binfile = 'dist/portainer-'+p+'-'+a; + if (grunt.file.isFile( ( p === 'windows' ) ? binfile+'.exe' : binfile )) { + return 'echo "Portainer binary exists"'; + } else { + return 'build/build_in_container.sh ' + p + ' ' + a; + } + } }, run: { command: [ 'docker rm -f portainer', 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app' ].join(';') + }, + downloadDockerBinary: { + command: function(p, a) { + if (p === 'windows') p = 'win'; + if (p === 'darwin') p = 'mac'; + if (a === 'amd64') a = 'x86_64'; + if (a === 'arm') a = 'armhf'; + if (a === 'arm64') a = 'aarch64'; + if (grunt.file.isFile( ( p === 'win' ) ? 'dist/docker.exe' : 'dist/docker' )) { + return 'echo "Docker binary exists"'; + } else { + return 'build/download_docker_binary.sh ' + p + ' ' + a + ' <%= shippedDockerVersion %>'; + } + } } }, replace: { diff --git a/vendor.yml b/vendor.yml index d71e768bf..c052c018d 100644 --- a/vendor.yml +++ b/vendor.yml @@ -12,6 +12,11 @@ js: - bower_components/toastr/toastr.js - bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/addons/fit/fit.js + - bower_components/js-yaml/dist/js-yaml.js + - bower_components/codemirror/lib/codemirror.js + - bower_components/codemirror/mode/yaml/yaml.js + - bower_components/codemirror/addon/lint/lint.js + - bower_components/codemirror/addon/lint/yaml-lint.js minified: - bower_components/jquery/dist/jquery.min.js - bower_components/bootstrap/dist/js/bootstrap.min.js @@ -25,6 +30,11 @@ js: - bower_components/toastr/toastr.min.js - bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/addons/fit/fit.js + - bower_components/js-yaml/dist/js-yaml.min.js + - bower_components/codemirror/lib/codemirror.js + - bower_components/codemirror/mode/yaml/yaml.js + - bower_components/codemirror/addon/lint/lint.js + - bower_components/codemirror/addon/lint/yaml-lint.js css: regular: - bower_components/bootstrap/dist/css/bootstrap.css @@ -35,6 +45,8 @@ css: - bower_components/toastr/toastr.css - bower_components/xterm.js/dist/xterm.css - bower_components/angularjs-slider/dist/rzslider.css + - bower_components/codemirror/lib/codemirror.css + - bower_components/codemirror/addon/lint/lint.css minified: - bower_components/bootstrap/dist/css/bootstrap.min.css - bower_components/rdash-ui/dist/css/rdash.min.css @@ -44,6 +56,8 @@ css: - bower_components/toastr/toastr.min.css - bower_components/xterm.js/dist/xterm.css - bower_components/angularjs-slider/dist/rzslider.min.css + - bower_components/codemirror/lib/codemirror.css + - bower_components/codemirror/addon/lint/lint.css angular: regular: - bower_components/angular/angular.js