diff --git a/LICENSE b/LICENSE index 03bda7a2e..231ab8be4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Portainer: Copyright (c) 2016 Portainer.io +Copyright (c) 2018 Portainer.io This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages @@ -14,46 +14,4 @@ freely, subject to the following restrictions: appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. -3. This notice may not be removed or altered from any source distribution. - -Portainer contains code which was originally under this license: - -UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -rdash-angular: Copyright (c) [2014] [Elliot Hesp] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +3. This notice may not be removed or altered from any source distribution. \ No newline at end of file diff --git a/README.md b/README.md index c84a7cd1e..1ee305f3f 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,18 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart **_Portainer_** has full support for the following Docker versions: * Docker 1.10 to the latest version -* Docker Swarm >= 1.2.3 +* Standalone Docker Swarm >= 1.2.3 _(**NOTE:** Use of Standalone Docker Swarm is being discouraged since the introduction of built-in Swarm Mode in Docker. While older versions of Portainer had support for Standalone Docker Swarm, Portainer 1.17.0 and newer **do not** support it. However, the built-in Swarm Mode of Docker is fully supported.)_ Partial support for the following Docker versions (some features may not be available): * Docker 1.9 + +## Licensing + +Portainer is licensed under the zlib license. See [LICENSE](./LICENSE) for reference. + +Portainer also contains the following code, which is licensed under the [MIT license](https://opensource.org/licenses/MIT): + +UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io) + +rdash-angular: Copyright (c) [2014] [Elliot Hesp] diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 8017d8add..e898d4ec2 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -2,84 +2,66 @@ package bolt import ( "log" - "os" + "path" "time" "github.com/boltdb/bolt" "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/dockerhub" + "github.com/portainer/portainer/bolt/endpoint" + "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/migrator" + "github.com/portainer/portainer/bolt/registry" + "github.com/portainer/portainer/bolt/resourcecontrol" + "github.com/portainer/portainer/bolt/settings" + "github.com/portainer/portainer/bolt/stack" + "github.com/portainer/portainer/bolt/tag" + "github.com/portainer/portainer/bolt/team" + "github.com/portainer/portainer/bolt/teammembership" + "github.com/portainer/portainer/bolt/user" + "github.com/portainer/portainer/bolt/version" +) + +const ( + databaseFileName = "portainer.db" ) // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - // Path where is stored the BoltDB database. - Path string - - // Services - UserService *UserService - TeamService *TeamService - TeamMembershipService *TeamMembershipService - EndpointService *EndpointService - EndpointGroupService *EndpointGroupService - ResourceControlService *ResourceControlService - VersionService *VersionService - SettingsService *SettingsService - RegistryService *RegistryService - DockerHubService *DockerHubService - StackService *StackService - - db *bolt.DB - checkForDataMigration bool + path string + db *bolt.DB + checkForDataMigration bool + fileService portainer.FileService + DockerHubService *dockerhub.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + UserService *user.Service + VersionService *version.Service } -const ( - databaseFileName = "portainer.db" - versionBucketName = "version" - userBucketName = "users" - teamBucketName = "teams" - teamMembershipBucketName = "team_membership" - endpointBucketName = "endpoints" - endpointGroupBucketName = "endpoint_groups" - resourceControlBucketName = "resource_control" - settingsBucketName = "settings" - registryBucketName = "registries" - dockerhubBucketName = "dockerhub" - stackBucketName = "stacks" -) - // NewStore initializes a new Store and the associated services -func NewStore(storePath string) (*Store, error) { +func NewStore(storePath string, fileService portainer.FileService) (*Store, error) { store := &Store{ - Path: storePath, - UserService: &UserService{}, - TeamService: &TeamService{}, - TeamMembershipService: &TeamMembershipService{}, - EndpointService: &EndpointService{}, - EndpointGroupService: &EndpointGroupService{}, - ResourceControlService: &ResourceControlService{}, - VersionService: &VersionService{}, - SettingsService: &SettingsService{}, - RegistryService: &RegistryService{}, - DockerHubService: &DockerHubService{}, - StackService: &StackService{}, + path: storePath, + fileService: fileService, } - store.UserService.store = store - store.TeamService.store = store - store.TeamMembershipService.store = store - store.EndpointService.store = store - store.EndpointGroupService.store = store - store.ResourceControlService.store = store - store.VersionService.store = store - 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) { - store.checkForDataMigration = false - } else if err != nil { + databasePath := path.Join(storePath, databaseFileName) + databaseFileExists, err := fileService.FileExists(databasePath) + if err != nil { return nil, err + } + + if !databaseFileExists { + store.checkForDataMigration = false } else { store.checkForDataMigration = true } @@ -89,29 +71,14 @@ func NewStore(storePath string) (*Store, error) { // Open opens and initializes the BoltDB database. func (store *Store) Open() error { - path := store.Path + "/" + databaseFileName - - db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) + databasePath := path.Join(store.path, databaseFileName) + db, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second}) if err != nil { return err } store.db = db - bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, - endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, - registryBucketName, dockerhubBucketName, stackBucketName} - - return db.Update(func(tx *bolt.Tx) error { - - for _, bucket := range bucketsToCreate { - _, err := tx.CreateBucketIfNotExists([]byte(bucket)) - if err != nil { - return err - } - } - - return nil - }) + return store.initServices() } // Init creates the default data set. @@ -128,6 +95,7 @@ func (store *Store) Init() error { Labels: []portainer.Pair{}, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Tags: []string{}, } return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup) @@ -147,28 +115,114 @@ func (store *Store) Close() error { // MigrateData automatically migrate the data based on the DBVersion. func (store *Store) MigrateData() error { if !store.checkForDataMigration { - err := store.VersionService.StoreDBVersion(portainer.DBVersion) - if err != nil { - return err - } - return nil + return store.VersionService.StoreDBVersion(portainer.DBVersion) } version, err := store.VersionService.DBVersion() - if err == portainer.ErrDBVersionNotFound { + if err == portainer.ErrObjectNotFound { version = 0 } else if err != nil { return err } if version < portainer.DBVersion { + migratorParams := &migrator.Parameters{ + DB: store.db, + DatabaseVersion: version, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + SettingsService: store.SettingsService, + StackService: store.StackService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, + } + migrator := migrator.NewMigrator(migratorParams) + log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion) - migrator := NewMigrator(store, version) err = migrator.Migrate() if err != nil { + log.Printf("An error occurred during database migration: %s\n", err) return err } } return nil } + +func (store *Store) initServices() error { + dockerhubService, err := dockerhub.NewService(store.db) + if err != nil { + return err + } + store.DockerHubService = dockerhubService + + endpointgroupService, err := endpointgroup.NewService(store.db) + if err != nil { + return err + } + store.EndpointGroupService = endpointgroupService + + endpointService, err := endpoint.NewService(store.db) + if err != nil { + return err + } + store.EndpointService = endpointService + + registryService, err := registry.NewService(store.db) + if err != nil { + return err + } + store.RegistryService = registryService + + resourcecontrolService, err := resourcecontrol.NewService(store.db) + if err != nil { + return err + } + store.ResourceControlService = resourcecontrolService + + settingsService, err := settings.NewService(store.db) + if err != nil { + return err + } + store.SettingsService = settingsService + + stackService, err := stack.NewService(store.db) + if err != nil { + return err + } + store.StackService = stackService + + tagService, err := tag.NewService(store.db) + if err != nil { + return err + } + store.TagService = tagService + + teammembershipService, err := teammembership.NewService(store.db) + if err != nil { + return err + } + store.TeamMembershipService = teammembershipService + + teamService, err := team.NewService(store.db) + if err != nil { + return err + } + store.TeamService = teamService + + userService, err := user.NewService(store.db) + if err != nil { + return err + } + store.UserService = userService + + versionService, err := version.NewService(store.db) + if err != nil { + return err + } + store.VersionService = versionService + + return nil +} diff --git a/api/bolt/dockerhub/dockerhub.go b/api/bolt/dockerhub/dockerhub.go new file mode 100644 index 000000000..0e4b4858c --- /dev/null +++ b/api/bolt/dockerhub/dockerhub.go @@ -0,0 +1,48 @@ +package dockerhub + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "dockerhub" + dockerHubKey = "DOCKERHUB" +) + +// Service represents a service for managing Dockerhub data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// DockerHub returns the DockerHub object. +func (service *Service) DockerHub() (*portainer.DockerHub, error) { + var dockerhub portainer.DockerHub + + err := internal.GetObject(service.db, BucketName, []byte(dockerHubKey), &dockerhub) + if err != nil { + return nil, err + } + + return &dockerhub, nil +} + +// UpdateDockerHub updates a DockerHub object. +func (service *Service) UpdateDockerHub(dockerhub *portainer.DockerHub) error { + return internal.UpdateObject(service.db, BucketName, []byte(dockerHubKey), dockerhub) +} diff --git a/api/bolt/dockerhub_service.go b/api/bolt/dockerhub_service.go deleted file mode 100644 index 34acd5594..000000000 --- a/api/bolt/dockerhub_service.go +++ /dev/null @@ -1,61 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// DockerHubService represents a service for managing registries. -type DockerHubService struct { - store *Store -} - -const ( - dbDockerHubKey = "DOCKERHUB" -) - -// DockerHub returns the DockerHub object. -func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(dockerhubBucketName)) - value := bucket.Get([]byte(dbDockerHubKey)) - if value == nil { - return portainer.ErrDockerHubNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var dockerhub portainer.DockerHub - err = internal.UnmarshalDockerHub(data, &dockerhub) - if err != nil { - return nil, err - } - return &dockerhub, nil -} - -// StoreDockerHub persists a DockerHub object. -func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(dockerhubBucketName)) - - data, err := internal.MarshalDockerHub(dockerhub) - if err != nil { - return err - } - - err = bucket.Put([]byte(dbDockerHubKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go new file mode 100644 index 000000000..79672eb36 --- /dev/null +++ b/api/bolt/endpoint/endpoint.go @@ -0,0 +1,138 @@ +package endpoint + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoints" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Endpoint returns an endpoint by ID. +func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + var endpoint portainer.Endpoint + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpoint) + if err != nil { + return nil, err + } + + return &endpoint, nil +} + +// UpdateEndpoint updates an endpoint. +func (service *Service) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpoint) +} + +// DeleteEndpoint deletes an endpoint. +func (service *Service) DeleteEndpoint(ID portainer.EndpointID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// Endpoints return an array containing all the endpoints. +func (service *Service) Endpoints() ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpoint portainer.Endpoint + err := internal.UnmarshalObject(v, &endpoint) + if err != nil { + return err + } + endpoints = append(endpoints, endpoint) + } + + return nil + }) + + return endpoints, err +} + +// CreateEndpoint assign an ID to a new endpoint and saves it. +func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpoint.ID)), data) + }) +} + +// Synchronize creates, updates and deletes endpoints inside a single transaction. +func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + for _, endpoint := range toCreate { + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpoint.ID)), data) + if err != nil { + return err + } + } + + for _, endpoint := range toUpdate { + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpoint.ID)), data) + if err != nil { + return err + } + } + + for _, endpoint := range toDelete { + err := bucket.Delete(internal.Itob(int(endpoint.ID))) + if err != nil { + return err + } + } + + return nil + }) +} diff --git a/api/bolt/endpoint_group_service.go b/api/bolt/endpoint_group_service.go deleted file mode 100644 index c52e95dac..000000000 --- a/api/bolt/endpoint_group_service.go +++ /dev/null @@ -1,114 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// EndpointGroupService represents a service for managing endpoint groups. -type EndpointGroupService struct { - store *Store -} - -// EndpointGroup returns an endpoint group by ID. -func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrEndpointGroupNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpointGroup portainer.EndpointGroup - err = internal.UnmarshalEndpointGroup(data, &endpointGroup) - if err != nil { - return nil, err - } - return &endpointGroup, nil -} - -// EndpointGroups return an array containing all the endpoint groups. -func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) { - var endpointGroups = make([]portainer.EndpointGroup, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var endpointGroup portainer.EndpointGroup - err := internal.UnmarshalEndpointGroup(v, &endpointGroup) - if err != nil { - return err - } - endpointGroups = append(endpointGroups, endpointGroup) - } - - return nil - }) - if err != nil { - return nil, err - } - - return endpointGroups, nil -} - -// CreateEndpointGroup assign an ID to a new endpoint group and saves it. -func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - - id, _ := bucket.NextSequence() - endpointGroup.ID = portainer.EndpointGroupID(id) - - data, err := internal.MarshalEndpointGroup(endpointGroup) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateEndpointGroup updates an endpoint group. -func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { - data, err := internal.MarshalEndpointGroup(endpointGroup) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteEndpointGroup deletes an endpoint group. -func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go deleted file mode 100644 index a92048d7a..000000000 --- a/api/bolt/endpoint_service.go +++ /dev/null @@ -1,154 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// EndpointService represents a service for managing endpoints. -type EndpointService struct { - store *Store -} - -// Endpoint returns an endpoint by ID. -func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrEndpointNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpoint portainer.Endpoint - err = internal.UnmarshalEndpoint(data, &endpoint) - if err != nil { - return nil, err - } - return &endpoint, nil -} - -// Endpoints return an array containing all the endpoints. -func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) { - var endpoints = make([]portainer.Endpoint, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var endpoint portainer.Endpoint - err := internal.UnmarshalEndpoint(v, &endpoint) - if err != nil { - return err - } - endpoints = append(endpoints, endpoint) - } - - return nil - }) - if err != nil { - return nil, err - } - - return endpoints, nil -} - -// Synchronize creates, updates and deletes endpoints inside a single transaction. -func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - - for _, endpoint := range toCreate { - err := storeNewEndpoint(endpoint, bucket) - if err != nil { - return err - } - } - - for _, endpoint := range toUpdate { - err := marshalAndStoreEndpoint(endpoint, bucket) - if err != nil { - return err - } - } - - for _, endpoint := range toDelete { - err := bucket.Delete(internal.Itob(int(endpoint.ID))) - if err != nil { - return err - } - } - - return nil - }) -} - -// CreateEndpoint assign an ID to a new endpoint and saves it. -func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err := storeNewEndpoint(endpoint, bucket) - if err != nil { - return err - } - return nil - }) -} - -// UpdateEndpoint updates an endpoint. -func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { - data, err := internal.MarshalEndpoint(endpoint) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteEndpoint deletes an endpoint. -func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} - -func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { - data, err := internal.MarshalEndpoint(endpoint) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(endpoint.ID)), data) - if err != nil { - return err - } - return nil -} - -func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { - id, _ := bucket.NextSequence() - endpoint.ID = portainer.EndpointID(id) - return marshalAndStoreEndpoint(endpoint, bucket) -} diff --git a/api/bolt/endpointgroup/endpointgroup.go b/api/bolt/endpointgroup/endpointgroup.go new file mode 100644 index 000000000..89398dbd5 --- /dev/null +++ b/api/bolt/endpointgroup/endpointgroup.go @@ -0,0 +1,95 @@ +package endpointgroup + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoint_groups" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// EndpointGroup returns an endpoint group by ID. +func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { + var endpointGroup portainer.EndpointGroup + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpointGroup) + if err != nil { + return nil, err + } + + return &endpointGroup, nil +} + +// UpdateEndpointGroup updates an endpoint group. +func (service *Service) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpointGroup) +} + +// DeleteEndpointGroup deletes an endpoint group. +func (service *Service) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// EndpointGroups return an array containing all the endpoint groups. +func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { + var endpointGroups = make([]portainer.EndpointGroup, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpointGroup portainer.EndpointGroup + err := internal.UnmarshalObject(v, &endpointGroup) + if err != nil { + return err + } + endpointGroups = append(endpointGroups, endpointGroup) + } + + return nil + }) + + return endpointGroups, err +} + +// CreateEndpointGroup assign an ID to a new endpoint group and saves it. +func (service *Service) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + endpointGroup.ID = portainer.EndpointGroupID(id) + + data, err := internal.MarshalObject(endpointGroup) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpointGroup.ID)), data) + }) +} diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go new file mode 100644 index 000000000..59ca7c87d --- /dev/null +++ b/api/bolt/internal/db.go @@ -0,0 +1,94 @@ +package internal + +import ( + "encoding/binary" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +// Itob returns an 8-byte big endian representation of v. +// This function is typically used for encoding integer IDs to byte slices +// so that they can be used as BoltDB keys. +func Itob(v int) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(v)) + return b +} + +// CreateBucket is a generic function used to create a bucket inside a bolt database. +func CreateBucket(db *bolt.DB, bucketName string) error { + return db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucketName)) + if err != nil { + return err + } + return nil + }) +} + +// GetObject is a generic function used to retrieve an unmarshalled object from a bolt database. +func GetObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { + var data []byte + + err := db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + value := bucket.Get(key) + if value == nil { + return portainer.ErrObjectNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + + return nil + }) + if err != nil { + return err + } + + return UnmarshalObject(data, object) +} + +// UpdateObject is a generic function used to update an object inside a bolt database. +func UpdateObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { + return db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + data, err := MarshalObject(object) + if err != nil { + return err + } + + err = bucket.Put(key, data) + if err != nil { + return err + } + + return nil + }) +} + +// DeleteObject is a generic function used to delete an object inside a bolt database. +func DeleteObject(db *bolt.DB, bucketName string, key []byte) error { + return db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + return bucket.Delete(key) + }) +} + +// GetNextIdentifier is a generic function that returns the specified bucket identifier incremented by 1. +func GetNextIdentifier(db *bolt.DB, bucketName string) int { + var identifier int + + db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + id := bucket.Sequence() + identifier = int(id) + return nil + }) + + identifier++ + return identifier +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go deleted file mode 100644 index b247268ee..000000000 --- a/api/bolt/internal/internal.go +++ /dev/null @@ -1,117 +0,0 @@ -package internal - -import ( - "github.com/portainer/portainer" - - "encoding/binary" - "encoding/json" -) - -// MarshalUser encodes a user to binary format. -func MarshalUser(user *portainer.User) ([]byte, error) { - return json.Marshal(user) -} - -// UnmarshalUser decodes a user from a binary data. -func UnmarshalUser(data []byte, user *portainer.User) error { - return json.Unmarshal(data, user) -} - -// MarshalTeam encodes a team to binary format. -func MarshalTeam(team *portainer.Team) ([]byte, error) { - return json.Marshal(team) -} - -// UnmarshalTeam decodes a team from a binary data. -func UnmarshalTeam(data []byte, team *portainer.Team) error { - return json.Unmarshal(data, team) -} - -// MarshalTeamMembership encodes a team membership to binary format. -func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) { - return json.Marshal(membership) -} - -// UnmarshalTeamMembership decodes a team membership from a binary data. -func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error { - return json.Unmarshal(data, membership) -} - -// MarshalEndpoint encodes an endpoint to binary format. -func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) { - return json.Marshal(endpoint) -} - -// UnmarshalEndpoint decodes an endpoint from a binary data. -func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { - return json.Unmarshal(data, endpoint) -} - -// MarshalEndpointGroup encodes an endpoint group to binary format. -func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) { - return json.Marshal(group) -} - -// UnmarshalEndpointGroup decodes an endpoint group from a binary data. -func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error { - return json.Unmarshal(data, group) -} - -// 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) -} - -// UnmarshalRegistry decodes a registry from a binary data. -func UnmarshalRegistry(data []byte, registry *portainer.Registry) error { - return json.Unmarshal(data, registry) -} - -// MarshalResourceControl encodes a resource control object to binary format. -func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) { - return json.Marshal(rc) -} - -// UnmarshalResourceControl decodes a resource control object from a binary data. -func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error { - return json.Unmarshal(data, rc) -} - -// MarshalSettings encodes a settings object to binary format. -func MarshalSettings(settings *portainer.Settings) ([]byte, error) { - return json.Marshal(settings) -} - -// UnmarshalSettings decodes a settings object from a binary data. -func UnmarshalSettings(data []byte, settings *portainer.Settings) error { - return json.Unmarshal(data, settings) -} - -// MarshalDockerHub encodes a Dockerhub object to binary format. -func MarshalDockerHub(settings *portainer.DockerHub) ([]byte, error) { - return json.Marshal(settings) -} - -// UnmarshalDockerHub decodes a Dockerhub object from a binary data. -func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error { - return json.Unmarshal(data, settings) -} - -// Itob returns an 8-byte big endian representation of v. -// This function is typically used for encoding integer IDs to byte slices -// so that they can be used as BoltDB keys. -func Itob(v int) []byte { - b := make([]byte, 8) - binary.BigEndian.PutUint64(b, uint64(v)) - return b -} diff --git a/api/bolt/internal/json.go b/api/bolt/internal/json.go new file mode 100644 index 000000000..9f69f06ee --- /dev/null +++ b/api/bolt/internal/json.go @@ -0,0 +1,15 @@ +package internal + +import ( + "encoding/json" +) + +// MarshalObject encodes an object to binary format +func MarshalObject(object interface{}) ([]byte, error) { + return json.Marshal(object) +} + +// UnmarshalObject decodes an object from binary data +func UnmarshalObject(data []byte, object interface{}) error { + return json.Unmarshal(data, object) +} diff --git a/api/bolt/migrate_dbversion4.go b/api/bolt/migrate_dbversion4.go deleted file mode 100644 index ace64f51e..000000000 --- a/api/bolt/migrate_dbversion4.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion5() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.AllowBindMountsForRegularUsers = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrate_dbversion5.go b/api/bolt/migrate_dbversion5.go deleted file mode 100644 index ef1bfd3ad..000000000 --- a/api/bolt/migrate_dbversion5.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion6() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.AllowPrivilegedModeForRegularUsers = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrate_dbversion6.go b/api/bolt/migrate_dbversion6.go deleted file mode 100644 index 95d53af61..000000000 --- a/api/bolt/migrate_dbversion6.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion7() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.DisplayDonationHeader = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go deleted file mode 100644 index 48242adaf..000000000 --- a/api/bolt/migrator.go +++ /dev/null @@ -1,128 +0,0 @@ -package bolt - -import "github.com/portainer/portainer" - -// Migrator defines a service to migrate data after a Portainer version update. -type Migrator struct { - UserService *UserService - EndpointService *EndpointService - ResourceControlService *ResourceControlService - SettingsService *SettingsService - VersionService *VersionService - CurrentDBVersion int - store *Store -} - -// NewMigrator creates a new Migrator. -func NewMigrator(store *Store, version int) *Migrator { - return &Migrator{ - UserService: store.UserService, - EndpointService: store.EndpointService, - ResourceControlService: store.ResourceControlService, - SettingsService: store.SettingsService, - VersionService: store.VersionService, - CurrentDBVersion: version, - store: store, - } -} - -// Migrate checks the database version and migrate the existing data to the most recent data model. -func (m *Migrator) Migrate() error { - - // Portainer < 1.12 - if m.CurrentDBVersion < 1 { - err := m.updateAdminUserToDBVersion1() - if err != nil { - return err - } - } - - // Portainer 1.12.x - if m.CurrentDBVersion < 2 { - err := m.updateResourceControlsToDBVersion2() - if err != nil { - return err - } - err = m.updateEndpointsToDBVersion2() - if err != nil { - return err - } - } - - // Portainer 1.13.x - if m.CurrentDBVersion < 3 { - err := m.updateSettingsToDBVersion3() - if err != nil { - return err - } - } - - // Portainer 1.14.0 - if m.CurrentDBVersion < 4 { - err := m.updateEndpointsToDBVersion4() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1235 - if m.CurrentDBVersion < 5 { - err := m.updateSettingsToVersion5() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1236 - if m.CurrentDBVersion < 6 { - err := m.updateSettingsToVersion6() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1449 - if m.CurrentDBVersion < 7 { - err := m.updateSettingsToVersion7() - if err != nil { - return err - } - } - - if m.CurrentDBVersion < 8 { - err := m.updateEndpointsToVersion8() - if err != nil { - return err - } - } - - // https: //github.com/portainer/portainer/issues/1396 - if m.CurrentDBVersion < 9 { - err := m.updateEndpointsToVersion9() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/461 - if m.CurrentDBVersion < 10 { - err := m.updateEndpointsToVersion10() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1906 - if m.CurrentDBVersion < 11 { - err := m.updateEndpointsToVersion11() - if err != nil { - return err - } - } - - err := m.VersionService.StoreDBVersion(portainer.DBVersion) - if err != nil { - return err - } - return nil -} diff --git a/api/bolt/migrate_dbversion0.go b/api/bolt/migrator/migrate_dbversion0.go similarity index 56% rename from api/bolt/migrate_dbversion0.go rename to api/bolt/migrator/migrate_dbversion0.go index f0223ee9e..4c2bdff12 100644 --- a/api/bolt/migrate_dbversion0.go +++ b/api/bolt/migrator/migrate_dbversion0.go @@ -1,19 +1,20 @@ -package bolt +package migrator import ( "github.com/boltdb/bolt" "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/user" ) func (m *Migrator) updateAdminUserToDBVersion1() error { - u, err := m.UserService.UserByUsername("admin") + u, err := m.userService.UserByUsername("admin") if err == nil { admin := &portainer.User{ Username: "admin", Password: u.Password, Role: portainer.AdministratorRole, } - err = m.UserService.CreateUser(admin) + err = m.userService.CreateUser(admin) if err != nil { return err } @@ -21,19 +22,15 @@ func (m *Migrator) updateAdminUserToDBVersion1() error { if err != nil { return err } - } else if err != nil && err != portainer.ErrUserNotFound { + } else if err != nil && err != portainer.ErrObjectNotFound { return err } return nil } func (m *Migrator) removeLegacyAdminUser() error { - return m.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete([]byte("admin")) - if err != nil { - return err - } - return nil + return m.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(user.BucketName)) + return bucket.Delete([]byte("admin")) }) } diff --git a/api/bolt/migrate_dbversion1.go b/api/bolt/migrator/migrate_dbversion1.go similarity index 83% rename from api/bolt/migrate_dbversion1.go rename to api/bolt/migrator/migrate_dbversion1.go index b34ba7867..063f58dc7 100644 --- a/api/bolt/migrate_dbversion1.go +++ b/api/bolt/migrator/migrate_dbversion1.go @@ -1,4 +1,4 @@ -package bolt +package migrator import ( "github.com/boltdb/bolt" @@ -16,7 +16,7 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { resourceControl.SubResourceIDs = []string{} resourceControl.TeamAccesses = []portainer.TeamResourceAccess{} - owner, err := m.UserService.User(resourceControl.OwnerID) + owner, err := m.userService.User(resourceControl.OwnerID) if err != nil { return err } @@ -33,7 +33,7 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess} } - err = m.ResourceControlService.CreateResourceControl(&resourceControl) + err = m.resourceControlService.CreateResourceControl(&resourceControl) if err != nil { return err } @@ -43,14 +43,14 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { } func (m *Migrator) updateEndpointsToDBVersion2() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.AuthorizedTeams = []portainer.TeamID{} - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } @@ -61,12 +61,12 @@ func (m *Migrator) updateEndpointsToDBVersion2() error { func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) { legacyResourceControls := make([]portainer.ResourceControl, 0) - err := m.store.db.View(func(tx *bolt.Tx) error { + err := m.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte("containerResourceControl")) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } @@ -78,7 +78,7 @@ func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl cursor = bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } @@ -90,7 +90,7 @@ func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl cursor = bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion10.go b/api/bolt/migrator/migrate_dbversion10.go similarity index 78% rename from api/bolt/migrate_dbversion10.go rename to api/bolt/migrator/migrate_dbversion10.go index 211d2497a..da55e3962 100644 --- a/api/bolt/migrate_dbversion10.go +++ b/api/bolt/migrator/migrate_dbversion10.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion11() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } @@ -18,7 +18,7 @@ func (m *Migrator) updateEndpointsToVersion11() error { } } - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrator/migrate_dbversion11.go b/api/bolt/migrator/migrate_dbversion11.go new file mode 100644 index 000000000..168d23984 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion11.go @@ -0,0 +1,127 @@ +package migrator + +import ( + "strconv" + "strings" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + "github.com/portainer/portainer/bolt/stack" +) + +func (m *Migrator) updateEndpointsToVersion12() error { + legacyEndpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Tags = []string{} + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointGroupsToVersion12() error { + legacyEndpointGroups, err := m.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, group := range legacyEndpointGroups { + group.Tags = []string{} + + err = m.endpointGroupService.UpdateEndpointGroup(group.ID, &group) + if err != nil { + return err + } + } + + return nil +} + +type legacyStack struct { + ID string `json:"Id"` + Name string `json:"Name"` + EndpointID portainer.EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []portainer.Pair `json:"Env"` + ProjectPath string +} + +func (m *Migrator) updateStacksToVersion12() error { + legacyStacks, err := m.retrieveLegacyStacks() + if err != nil { + return err + } + + for _, legacyStack := range legacyStacks { + err := m.convertLegacyStack(&legacyStack) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) convertLegacyStack(s *legacyStack) error { + stackID := m.stackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: s.Name, + Type: portainer.DockerSwarmStack, + SwarmID: s.SwarmID, + EndpointID: 0, + EntryPoint: s.EntryPoint, + Env: s.Env, + } + + stack.ProjectPath = strings.Replace(s.ProjectPath, s.ID, strconv.Itoa(stackID), 1) + err := m.fileService.Rename(s.ProjectPath, stack.ProjectPath) + if err != nil { + return err + } + + err = m.deleteLegacyStack(s.ID) + if err != nil { + return err + } + + return m.stackService.CreateStack(stack) +} + +func (m *Migrator) deleteLegacyStack(legacyID string) error { + return m.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stack.BucketName)) + return bucket.Delete([]byte(legacyID)) + }) +} + +func (m *Migrator) retrieveLegacyStacks() ([]legacyStack, error) { + var legacyStacks = make([]legacyStack, 0) + err := m.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stack.BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack legacyStack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + legacyStacks = append(legacyStacks, stack) + } + + return nil + }) + + return legacyStacks, err +} diff --git a/api/bolt/migrate_dbversion2.go b/api/bolt/migrator/migrate_dbversion2.go similarity index 70% rename from api/bolt/migrate_dbversion2.go rename to api/bolt/migrator/migrate_dbversion2.go index 38a3e4b50..9488f50f9 100644 --- a/api/bolt/migrate_dbversion2.go +++ b/api/bolt/migrator/migrate_dbversion2.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateSettingsToDBVersion3() error { - legacySettings, err := m.SettingsService.Settings() + legacySettings, err := m.settingsService.Settings() if err != nil { return err } @@ -16,10 +16,5 @@ func (m *Migrator) updateSettingsToDBVersion3() error { }, } - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil + return m.settingsService.UpdateSettings(legacySettings) } diff --git a/api/bolt/migrate_dbversion3.go b/api/bolt/migrator/migrate_dbversion3.go similarity index 80% rename from api/bolt/migrate_dbversion3.go rename to api/bolt/migrator/migrate_dbversion3.go index d8679ca68..75636dc97 100644 --- a/api/bolt/migrate_dbversion3.go +++ b/api/bolt/migrator/migrate_dbversion3.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToDBVersion4() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } @@ -17,7 +17,8 @@ func (m *Migrator) updateEndpointsToDBVersion4() error { endpoint.TLSConfig.TLSCertPath = endpoint.TLSCertPath endpoint.TLSConfig.TLSKeyPath = endpoint.TLSKeyPath } - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrator/migrate_dbversion4.go b/api/bolt/migrator/migrate_dbversion4.go new file mode 100644 index 000000000..0bc7c84e4 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion4.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion5() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowBindMountsForRegularUsers = true + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrate_dbversion5.go b/api/bolt/migrator/migrate_dbversion5.go new file mode 100644 index 000000000..f1ccb5734 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion5.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion6() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowPrivilegedModeForRegularUsers = true + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrate_dbversion6.go b/api/bolt/migrator/migrate_dbversion6.go new file mode 100644 index 000000000..860a56ff0 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion6.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion7() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + legacySettings.DisplayDonationHeader = true + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrate_dbversion7.go b/api/bolt/migrator/migrate_dbversion7.go similarity index 67% rename from api/bolt/migrate_dbversion7.go rename to api/bolt/migrator/migrate_dbversion7.go index bcdd199f2..b248e9e42 100644 --- a/api/bolt/migrate_dbversion7.go +++ b/api/bolt/migrator/migrate_dbversion7.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion8() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.Extensions = []portainer.EndpointExtension{} - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion8.go b/api/bolt/migrator/migrate_dbversion8.go similarity index 67% rename from api/bolt/migrate_dbversion8.go rename to api/bolt/migrator/migrate_dbversion8.go index 7ef77806d..99a73bf11 100644 --- a/api/bolt/migrate_dbversion8.go +++ b/api/bolt/migrator/migrate_dbversion8.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion9() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.GroupID = portainer.EndpointGroupID(1) - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion9.go b/api/bolt/migrator/migrate_dbversion9.go similarity index 67% rename from api/bolt/migrate_dbversion9.go rename to api/bolt/migrator/migrate_dbversion9.go index 1882f55c7..f4a52d398 100644 --- a/api/bolt/migrate_dbversion9.go +++ b/api/bolt/migrator/migrate_dbversion9.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion10() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.Type = portainer.DockerEnvironment - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go new file mode 100644 index 000000000..5e32366ff --- /dev/null +++ b/api/bolt/migrator/migrator.go @@ -0,0 +1,174 @@ +package migrator + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/endpoint" + "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/resourcecontrol" + "github.com/portainer/portainer/bolt/settings" + "github.com/portainer/portainer/bolt/stack" + "github.com/portainer/portainer/bolt/user" + "github.com/portainer/portainer/bolt/version" +) + +type ( + // Migrator defines a service to migrate data after a Portainer version update. + Migrator struct { + currentDBVersion int + db *bolt.DB + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + resourceControlService *resourcecontrol.Service + settingsService *settings.Service + stackService *stack.Service + userService *user.Service + versionService *version.Service + fileService portainer.FileService + } + + // Parameters represents the required parameters to create a new Migrator instance. + Parameters struct { + DB *bolt.DB + DatabaseVersion int + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + UserService *user.Service + VersionService *version.Service + FileService portainer.FileService + } +) + +// NewMigrator creates a new Migrator. +func NewMigrator(parameters *Parameters) *Migrator { + return &Migrator{ + db: parameters.DB, + currentDBVersion: parameters.DatabaseVersion, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + resourceControlService: parameters.ResourceControlService, + settingsService: parameters.SettingsService, + stackService: parameters.StackService, + userService: parameters.UserService, + versionService: parameters.VersionService, + fileService: parameters.FileService, + } +} + +// Migrate checks the database version and migrate the existing data to the most recent data model. +func (m *Migrator) Migrate() error { + + // Portainer < 1.12 + if m.currentDBVersion < 1 { + err := m.updateAdminUserToDBVersion1() + if err != nil { + return err + } + } + + // Portainer 1.12.x + if m.currentDBVersion < 2 { + err := m.updateResourceControlsToDBVersion2() + if err != nil { + return err + } + err = m.updateEndpointsToDBVersion2() + if err != nil { + return err + } + } + + // Portainer 1.13.x + if m.currentDBVersion < 3 { + err := m.updateSettingsToDBVersion3() + if err != nil { + return err + } + } + + // Portainer 1.14.0 + if m.currentDBVersion < 4 { + err := m.updateEndpointsToDBVersion4() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1235 + if m.currentDBVersion < 5 { + err := m.updateSettingsToVersion5() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1236 + if m.currentDBVersion < 6 { + err := m.updateSettingsToVersion6() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1449 + if m.currentDBVersion < 7 { + err := m.updateSettingsToVersion7() + if err != nil { + return err + } + } + + if m.currentDBVersion < 8 { + err := m.updateEndpointsToVersion8() + if err != nil { + return err + } + } + + // https: //github.com/portainer/portainer/issues/1396 + if m.currentDBVersion < 9 { + err := m.updateEndpointsToVersion9() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/461 + if m.currentDBVersion < 10 { + err := m.updateEndpointsToVersion10() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1906 + if m.currentDBVersion < 11 { + err := m.updateEndpointsToVersion11() + if err != nil { + return err + } + } + + // Portainer 1.18.0 + if m.currentDBVersion < 12 { + err := m.updateEndpointsToVersion12() + if err != nil { + return err + } + + err = m.updateEndpointGroupsToVersion12() + if err != nil { + return err + } + + err = m.updateStacksToVersion12() + if err != nil { + return err + } + } + + return m.versionService.StoreDBVersion(portainer.DBVersion) +} diff --git a/api/bolt/registry/registry.go b/api/bolt/registry/registry.go new file mode 100644 index 000000000..2fbfbeb90 --- /dev/null +++ b/api/bolt/registry/registry.go @@ -0,0 +1,95 @@ +package registry + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "registries" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Registry returns an registry by ID. +func (service *Service) Registry(ID portainer.RegistryID) (*portainer.Registry, error) { + var registry portainer.Registry + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, ®istry) + if err != nil { + return nil, err + } + + return ®istry, nil +} + +// Registries returns an array containing all the registries. +func (service *Service) Registries() ([]portainer.Registry, error) { + var registries = make([]portainer.Registry, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var registry portainer.Registry + err := internal.UnmarshalObject(v, ®istry) + if err != nil { + return err + } + registries = append(registries, registry) + } + + return nil + }) + + return registries, err +} + +// CreateRegistry creates a new registry. +func (service *Service) CreateRegistry(registry *portainer.Registry) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + registry.ID = portainer.RegistryID(id) + + data, err := internal.MarshalObject(registry) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(registry.ID)), data) + }) +} + +// UpdateRegistry updates an registry. +func (service *Service) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, registry) +} + +// DeleteRegistry deletes an registry. +func (service *Service) DeleteRegistry(ID portainer.RegistryID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/registry_service.go b/api/bolt/registry_service.go deleted file mode 100644 index 4c0c393ae..000000000 --- a/api/bolt/registry_service.go +++ /dev/null @@ -1,114 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// RegistryService represents a service for managing registries. -type RegistryService struct { - store *Store -} - -// Registry returns an registry by ID. -func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrRegistryNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var registry portainer.Registry - err = internal.UnmarshalRegistry(data, ®istry) - if err != nil { - return nil, err - } - return ®istry, nil -} - -// Registries returns an array containing all the registries. -func (service *RegistryService) Registries() ([]portainer.Registry, error) { - var registries = make([]portainer.Registry, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var registry portainer.Registry - err := internal.UnmarshalRegistry(v, ®istry) - if err != nil { - return err - } - registries = append(registries, registry) - } - - return nil - }) - if err != nil { - return nil, err - } - - return registries, nil -} - -// CreateRegistry creates a new registry. -func (service *RegistryService) CreateRegistry(registry *portainer.Registry) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - - id, _ := bucket.NextSequence() - registry.ID = portainer.RegistryID(id) - - data, err := internal.MarshalRegistry(registry) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(registry.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateRegistry updates an registry. -func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { - data, err := internal.MarshalRegistry(registry) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteRegistry deletes an registry. -func (service *RegistryService) DeleteRegistry(ID portainer.RegistryID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resource_control_service.go b/api/bolt/resource_control_service.go deleted file mode 100644 index 2986d5add..000000000 --- a/api/bolt/resource_control_service.go +++ /dev/null @@ -1,148 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// ResourceControlService represents a service for managing resource controls. -type ResourceControlService struct { - store *Store -} - -// ResourceControl returns a ResourceControl object by ID -func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrResourceControlNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var resourceControl portainer.ResourceControl - err = internal.UnmarshalResourceControl(data, &resourceControl) - if err != nil { - return nil, err - } - return &resourceControl, nil -} - -// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal -// to the main ResourceID or in SubResourceIDs -func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { - var resourceControl *portainer.ResourceControl - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var rc portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &rc) - if err != nil { - return err - } - if rc.ResourceID == resourceID { - resourceControl = &rc - } - for _, subResourceID := range rc.SubResourceIDs { - if subResourceID == resourceID { - resourceControl = &rc - } - } - } - - if resourceControl == nil { - return portainer.ErrResourceControlNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return resourceControl, nil -} - -// ResourceControls returns all the ResourceControl objects -func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) { - var rcs = make([]portainer.ResourceControl, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) - if err != nil { - return err - } - rcs = append(rcs, resourceControl) - } - - return nil - }) - if err != nil { - return nil, err - } - - return rcs, nil -} - -// CreateResourceControl creates a new ResourceControl object -func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - id, _ := bucket.NextSequence() - resourceControl.ID = portainer.ResourceControlID(id) - data, err := internal.MarshalResourceControl(resourceControl) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(resourceControl.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateResourceControl saves a ResourceControl object. -func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { - data, err := internal.MarshalResourceControl(resourceControl) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// DeleteResourceControl deletes a ResourceControl object by ID -func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resourcecontrol/resourcecontrol.go b/api/bolt/resourcecontrol/resourcecontrol.go new file mode 100644 index 000000000..222bafd79 --- /dev/null +++ b/api/bolt/resourcecontrol/resourcecontrol.go @@ -0,0 +1,134 @@ +package resourcecontrol + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "resource_control" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// ResourceControl returns a ResourceControl object by ID +func (service *Service) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { + var resourceControl portainer.ResourceControl + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &resourceControl) + if err != nil { + return nil, err + } + + return &resourceControl, nil +} + +// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs +func (service *Service) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { + var resourceControl *portainer.ResourceControl + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalObject(v, &rc) + if err != nil { + return err + } + + if rc.ResourceID == resourceID { + resourceControl = &rc + break + } + + for _, subResourceID := range rc.SubResourceIDs { + if subResourceID == resourceID { + resourceControl = &rc + break + } + } + } + + if resourceControl == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return resourceControl, err +} + +// ResourceControls returns all the ResourceControl objects +func (service *Service) ResourceControls() ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalObject(v, &resourceControl) + if err != nil { + return err + } + rcs = append(rcs, resourceControl) + } + + return nil + }) + + return rcs, err +} + +// CreateResourceControl creates a new ResourceControl object +func (service *Service) CreateResourceControl(resourceControl *portainer.ResourceControl) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + resourceControl.ID = portainer.ResourceControlID(id) + + data, err := internal.MarshalObject(resourceControl) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(resourceControl.ID)), data) + }) +} + +// UpdateResourceControl saves a ResourceControl object. +func (service *Service) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, resourceControl) +} + +// DeleteResourceControl deletes a ResourceControl object by ID +func (service *Service) DeleteResourceControl(ID portainer.ResourceControlID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/settings/settings.go b/api/bolt/settings/settings.go new file mode 100644 index 000000000..6e1d4bc82 --- /dev/null +++ b/api/bolt/settings/settings.go @@ -0,0 +1,48 @@ +package settings + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "settings" + settingsKey = "SETTINGS" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Settings retrieve the settings object. +func (service *Service) Settings() (*portainer.Settings, error) { + var settings portainer.Settings + + err := internal.GetObject(service.db, BucketName, []byte(settingsKey), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a Settings object. +func (service *Service) UpdateSettings(settings *portainer.Settings) error { + return internal.UpdateObject(service.db, BucketName, []byte(settingsKey), settings) +} diff --git a/api/bolt/settings_service.go b/api/bolt/settings_service.go deleted file mode 100644 index 9ea7cc2ab..000000000 --- a/api/bolt/settings_service.go +++ /dev/null @@ -1,61 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// SettingsService represents a service to manage application settings. -type SettingsService struct { - store *Store -} - -const ( - dbSettingsKey = "SETTINGS" -) - -// Settings retrieve the settings object. -func (service *SettingsService) Settings() (*portainer.Settings, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(settingsBucketName)) - value := bucket.Get([]byte(dbSettingsKey)) - if value == nil { - return portainer.ErrSettingsNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var settings portainer.Settings - err = internal.UnmarshalSettings(data, &settings) - if err != nil { - return nil, err - } - return &settings, nil -} - -// StoreSettings persists a Settings object. -func (service *SettingsService) StoreSettings(settings *portainer.Settings) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(settingsBucketName)) - - data, err := internal.MarshalSettings(settings) - if err != nil { - return err - } - - err = bucket.Put([]byte(dbSettingsKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go new file mode 100644 index 000000000..1bd8e159b --- /dev/null +++ b/api/bolt/stack/stack.go @@ -0,0 +1,134 @@ +package stack + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "stacks" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Stack returns a stack object by ID. +func (service *Service) Stack(ID portainer.StackID) (*portainer.Stack, error) { + var stack portainer.Stack + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &stack) + if err != nil { + return nil, err + } + + return &stack, nil +} + +// StackByName returns a stack object by name. +func (service *Service) StackByName(name string) (*portainer.Stack, error) { + var stack *portainer.Stack + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Stack + err := internal.UnmarshalObject(v, &t) + if err != nil { + return err + } + + if t.Name == name { + stack = &t + break + } + } + + if stack == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return stack, err +} + +// Stacks returns an array containing all the stacks. +func (service *Service) Stacks() ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack portainer.Stack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + stacks = append(stacks, stack) + } + + return nil + }) + + return stacks, err +} + +// GetNextIdentifier returns the next identifier for a stack. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} + +// CreateStack creates a new stack. +func (service *Service) CreateStack(stack *portainer.Stack) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + // We manually manage sequences for stacks + err := bucket.SetSequence(uint64(stack.ID)) + if err != nil { + return err + } + + data, err := internal.MarshalObject(stack) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(stack.ID)), data) + }) +} + +// UpdateStack updates a stack. +func (service *Service) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, stack) +} + +// DeleteStack deletes a stack. +func (service *Service) DeleteStack(ID portainer.StackID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go deleted file mode 100644 index bdbaac791..000000000 --- a/api/bolt/stack_service.go +++ /dev/null @@ -1,138 +0,0 @@ -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/bolt/tag/tag.go b/api/bolt/tag/tag.go new file mode 100644 index 000000000..796b463dd --- /dev/null +++ b/api/bolt/tag/tag.go @@ -0,0 +1,76 @@ +package tag + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "tags" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Tags return an array containing all the tags. +func (service *Service) Tags() ([]portainer.Tag, error) { + var tags = make([]portainer.Tag, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var tag portainer.Tag + err := internal.UnmarshalObject(v, &tag) + if err != nil { + return err + } + tags = append(tags, tag) + } + + return nil + }) + + return tags, err +} + +// CreateTag creates a new tag. +func (service *Service) CreateTag(tag *portainer.Tag) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + tag.ID = portainer.TagID(id) + + data, err := internal.MarshalObject(tag) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(tag.ID)), data) + }) +} + +// DeleteTag deletes a tag. +func (service *Service) DeleteTag(ID portainer.TagID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/team/team.go b/api/bolt/team/team.go new file mode 100644 index 000000000..189a16a8a --- /dev/null +++ b/api/bolt/team/team.go @@ -0,0 +1,126 @@ +package team + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "teams" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Team returns a Team by ID +func (service *Service) Team(ID portainer.TeamID) (*portainer.Team, error) { + var team portainer.Team + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &team) + if err != nil { + return nil, err + } + + return &team, nil +} + +// TeamByName returns a team by name. +func (service *Service) TeamByName(name string) (*portainer.Team, error) { + var team *portainer.Team + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Team + err := internal.UnmarshalObject(v, &t) + if err != nil { + return err + } + + if t.Name == name { + team = &t + break + } + } + + if team == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return team, err +} + +// Teams return an array containing all the teams. +func (service *Service) Teams() ([]portainer.Team, error) { + var teams = make([]portainer.Team, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var team portainer.Team + err := internal.UnmarshalObject(v, &team) + if err != nil { + return err + } + teams = append(teams, team) + } + + return nil + }) + + return teams, err +} + +// UpdateTeam saves a Team. +func (service *Service) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, team) +} + +// CreateTeam creates a new Team. +func (service *Service) CreateTeam(team *portainer.Team) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + team.ID = portainer.TeamID(id) + + data, err := internal.MarshalObject(team) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(team.ID)), data) + }) +} + +// DeleteTeam deletes a Team. +func (service *Service) DeleteTeam(ID portainer.TeamID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/team_membership_service.go b/api/bolt/team_membership_service.go deleted file mode 100644 index da2b47266..000000000 --- a/api/bolt/team_membership_service.go +++ /dev/null @@ -1,217 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// TeamMembershipService represents a service for managing TeamMembership objects. -type TeamMembershipService struct { - store *Store -} - -// TeamMembership returns a TeamMembership object by ID -func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrTeamMembershipNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var membership portainer.TeamMembership - err = internal.UnmarshalTeamMembership(data, &membership) - if err != nil { - return nil, err - } - return &membership, nil -} - -// TeamMemberships return an array containing all the TeamMembership objects. -func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) { - var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) - if err != nil { - return err - } - memberships = append(memberships, membership) - } - - return nil - }) - if err != nil { - return nil, err - } - - return memberships, nil -} - -// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. -func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { - var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) - if err != nil { - return err - } - if membership.UserID == userID { - memberships = append(memberships, membership) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return memberships, nil -} - -// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. -func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { - var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) - if err != nil { - return err - } - if membership.TeamID == teamID { - memberships = append(memberships, membership) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return memberships, nil -} - -// UpdateTeamMembership saves a TeamMembership object. -func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { - data, err := internal.MarshalTeamMembership(membership) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// CreateTeamMembership creates a new TeamMembership object. -func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - id, _ := bucket.NextSequence() - membership.ID = portainer.TeamMembershipID(id) - - data, err := internal.MarshalTeamMembership(membership) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(membership.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteTeamMembership deletes a TeamMembership object. -func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} - -// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. -func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) - if err != nil { - return err - } - if membership.UserID == userID { - err := bucket.Delete(internal.Itob(int(membership.ID))) - if err != nil { - return err - } - } - } - - return nil - }) -} - -// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. -func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) - if err != nil { - return err - } - if membership.TeamID == teamID { - err := bucket.Delete(internal.Itob(int(membership.ID))) - if err != nil { - return err - } - } - } - - return nil - }) -} diff --git a/api/bolt/team_service.go b/api/bolt/team_service.go deleted file mode 100644 index 2830e7783..000000000 --- a/api/bolt/team_service.go +++ /dev/null @@ -1,144 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// TeamService represents a service for managing teams. -type TeamService struct { - store *Store -} - -// Team returns a Team by ID -func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrTeamNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var team portainer.Team - err = internal.UnmarshalTeam(data, &team) - if err != nil { - return nil, err - } - return &team, nil -} - -// TeamByName returns a team by name. -func (service *TeamService) TeamByName(name string) (*portainer.Team, error) { - var team *portainer.Team - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var t portainer.Team - err := internal.UnmarshalTeam(v, &t) - if err != nil { - return err - } - if t.Name == name { - team = &t - } - } - - if team == nil { - return portainer.ErrTeamNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return team, nil -} - -// Teams return an array containing all the teams. -func (service *TeamService) Teams() ([]portainer.Team, error) { - var teams = make([]portainer.Team, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var team portainer.Team - err := internal.UnmarshalTeam(v, &team) - if err != nil { - return err - } - teams = append(teams, team) - } - - return nil - }) - if err != nil { - return nil, err - } - - return teams, nil -} - -// UpdateTeam saves a Team. -func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { - data, err := internal.MarshalTeam(team) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// CreateTeam creates a new Team. -func (service *TeamService) CreateTeam(team *portainer.Team) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - - id, _ := bucket.NextSequence() - team.ID = portainer.TeamID(id) - - data, err := internal.MarshalTeam(team) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(team.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteTeam deletes a Team. -func (service *TeamService) DeleteTeam(ID portainer.TeamID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/teammembership/teammembership.go b/api/bolt/teammembership/teammembership.go new file mode 100644 index 000000000..2f09e0ad7 --- /dev/null +++ b/api/bolt/teammembership/teammembership.go @@ -0,0 +1,197 @@ +package teammembership + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "team_membership" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// TeamMembership returns a TeamMembership object by ID +func (service *Service) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { + var membership portainer.TeamMembership + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &membership) + if err != nil { + return nil, err + } + + return &membership, nil +} + +// TeamMemberships return an array containing all the TeamMembership objects. +func (service *Service) TeamMemberships() ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalObject(v, &membership) + if err != nil { + return err + } + memberships = append(memberships, membership) + } + + return nil + }) + + return memberships, err +} + +// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. +func (service *Service) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalObject(v, &membership) + if err != nil { + return err + } + + if membership.UserID == userID { + memberships = append(memberships, membership) + } + } + + return nil + }) + + return memberships, err +} + +// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. +func (service *Service) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalObject(v, &membership) + if err != nil { + return err + } + + if membership.TeamID == teamID { + memberships = append(memberships, membership) + } + } + + return nil + }) + + return memberships, err +} + +// UpdateTeamMembership saves a TeamMembership object. +func (service *Service) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, membership) +} + +// CreateTeamMembership creates a new TeamMembership object. +func (service *Service) CreateTeamMembership(membership *portainer.TeamMembership) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + membership.ID = portainer.TeamMembershipID(id) + + data, err := internal.MarshalObject(membership) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(membership.ID)), data) + }) +} + +// DeleteTeamMembership deletes a TeamMembership object. +func (service *Service) DeleteTeamMembership(ID portainer.TeamMembershipID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. +func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalObject(v, &membership) + if err != nil { + return err + } + + if membership.UserID == userID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} + +// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. +func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var membership portainer.TeamMembership + err := internal.UnmarshalObject(v, &membership) + if err != nil { + return err + } + + if membership.TeamID == teamID { + err := bucket.Delete(internal.Itob(int(membership.ID))) + if err != nil { + return err + } + } + } + + return nil + }) +} diff --git a/api/bolt/user/user.go b/api/bolt/user/user.go new file mode 100644 index 000000000..03a3a8d88 --- /dev/null +++ b/api/bolt/user/user.go @@ -0,0 +1,149 @@ +package user + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "users" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// User returns a user by ID +func (service *Service) User(ID portainer.UserID) (*portainer.User, error) { + var user portainer.User + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +// UserByUsername returns a user by username. +func (service *Service) UserByUsername(username string) (*portainer.User, error) { + var user *portainer.User + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var u portainer.User + err := internal.UnmarshalObject(v, &u) + if err != nil { + return err + } + + if u.Username == username { + user = &u + break + } + } + + if user == nil { + return portainer.ErrObjectNotFound + } + return nil + }) + + return user, err +} + +// Users return an array containing all the users. +func (service *Service) Users() ([]portainer.User, error) { + var users = make([]portainer.User, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalObject(v, &user) + if err != nil { + return err + } + users = append(users, user) + } + + return nil + }) + + return users, err +} + +// UsersByRole return an array containing all the users with the specified role. +func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalObject(v, &user) + if err != nil { + return err + } + + if user.Role == role { + users = append(users, user) + } + } + return nil + }) + + return users, err +} + +// UpdateUser saves a user. +func (service *Service) UpdateUser(ID portainer.UserID, user *portainer.User) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, user) +} + +// CreateUser creates a new user. +func (service *Service) CreateUser(user *portainer.User) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + user.ID = portainer.UserID(id) + + data, err := internal.MarshalObject(user) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(user.ID)), data) + }) +} + +// DeleteUser deletes a user. +func (service *Service) DeleteUser(ID portainer.UserID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go deleted file mode 100644 index 1e1c68f40..000000000 --- a/api/bolt/user_service.go +++ /dev/null @@ -1,170 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// UserService represents a service for managing users. -type UserService struct { - store *Store -} - -// User returns a user by ID -func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrUserNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var user portainer.User - err = internal.UnmarshalUser(data, &user) - if err != nil { - return nil, err - } - return &user, nil -} - -// UserByUsername returns a user by username. -func (service *UserService) UserByUsername(username string) (*portainer.User, error) { - var user *portainer.User - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var u portainer.User - err := internal.UnmarshalUser(v, &u) - if err != nil { - return err - } - if u.Username == username { - user = &u - } - } - - if user == nil { - return portainer.ErrUserNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return user, nil -} - -// Users return an array containing all the users. -func (service *UserService) Users() ([]portainer.User, error) { - var users = make([]portainer.User, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var user portainer.User - err := internal.UnmarshalUser(v, &user) - if err != nil { - return err - } - users = append(users, user) - } - - return nil - }) - if err != nil { - return nil, err - } - - return users, nil -} - -// UsersByRole return an array containing all the users with the specified role. -func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { - var users = make([]portainer.User, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var user portainer.User - err := internal.UnmarshalUser(v, &user) - if err != nil { - return err - } - if user.Role == role { - users = append(users, user) - } - } - return nil - }) - if err != nil { - return nil, err - } - - return users, nil -} - -// UpdateUser saves a user. -func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { - data, err := internal.MarshalUser(user) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// CreateUser creates a new user. -func (service *UserService) CreateUser(user *portainer.User) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - id, _ := bucket.NextSequence() - user.ID = portainer.UserID(id) - - data, err := internal.MarshalUser(user) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(user.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteUser deletes a user. -func (service *UserService) DeleteUser(ID portainer.UserID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/version/version.go b/api/bolt/version/version.go new file mode 100644 index 000000000..52a7b4be4 --- /dev/null +++ b/api/bolt/version/version.go @@ -0,0 +1,66 @@ +package version + +import ( + "strconv" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "version" + versionKey = "DB_VERSION" +) + +// Service represents a service to manage stored versions. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// DBVersion retrieves the stored database version. +func (service *Service) DBVersion() (int, error) { + var data []byte + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + value := bucket.Get([]byte(versionKey)) + if value == nil { + return portainer.ErrObjectNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + + return nil + }) + if err != nil { + return 0, err + } + + return strconv.Atoi(string(data)) +} + +// StoreDBVersion store the database version. +func (service *Service) StoreDBVersion(version int) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data := []byte(strconv.Itoa(version)) + return bucket.Put([]byte(versionKey), data) + }) +} diff --git a/api/bolt/version_service.go b/api/bolt/version_service.go deleted file mode 100644 index 1f35cfc40..000000000 --- a/api/bolt/version_service.go +++ /dev/null @@ -1,58 +0,0 @@ -package bolt - -import ( - "strconv" - - "github.com/portainer/portainer" - - "github.com/boltdb/bolt" -) - -// VersionService represents a service to manage stored versions. -type VersionService struct { - store *Store -} - -const ( - dBVersionKey = "DB_VERSION" -) - -// DBVersion retrieves the stored database version. -func (service *VersionService) DBVersion() (int, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(versionBucketName)) - value := bucket.Get([]byte(dBVersionKey)) - if value == nil { - return portainer.ErrDBVersionNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return 0, err - } - - dbVersion, err := strconv.Atoi(string(data)) - if err != nil { - return 0, err - } - - return dbVersion, nil -} - -// StoreDBVersion store the database version. -func (service *VersionService) StoreDBVersion(version int) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(versionBucketName)) - - data := []byte(strconv.Itoa(version)) - err := bucket.Put([]byte(dBVersionKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 3c2dba7c6..54b7ebb5b 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/http/client" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" + "github.com/portainer/portainer/libcompose" "log" ) @@ -41,8 +42,8 @@ func initFileService(dataStorePath string) portainer.FileService { return fileService } -func initStore(dataStorePath string) *bolt.Store { - store, err := bolt.NewStore(dataStorePath) +func initStore(dataStorePath string, fileService portainer.FileService) *bolt.Store { + store, err := bolt.NewStore(dataStorePath, fileService) if err != nil { log.Fatal(err) } @@ -64,8 +65,12 @@ func initStore(dataStorePath string) *bolt.Store { return store } -func initStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) { - return exec.NewStackManager(assetsPath, dataStorePath, signatureService, fileService) +func initComposeStackManager(dataStorePath string) portainer.ComposeStackManager { + return libcompose.NewComposeStackManager(dataStorePath) +} + +func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -120,13 +125,13 @@ func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portaine func initDockerHub(dockerHubService portainer.DockerHubService) error { _, err := dockerHubService.DockerHub() - if err == portainer.ErrDockerHubNotFound { + if err == portainer.ErrObjectNotFound { dockerhub := &portainer.DockerHub{ Authentication: false, Username: "", Password: "", } - return dockerHubService.StoreDockerHub(dockerhub) + return dockerHubService.UpdateDockerHub(dockerhub) } else if err != nil { return err } @@ -136,10 +141,9 @@ func initDockerHub(dockerHubService portainer.DockerHubService) error { func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error { _, err := settingsService.Settings() - if err == portainer.ErrSettingsNotFound { + if err == portainer.ErrObjectNotFound { settings := &portainer.Settings{ LogoURL: *flags.Logo, - DisplayDonationHeader: true, DisplayExternalContributors: false, AuthenticationMethod: portainer.AuthenticationInternal, LDAPSettings: portainer.LDAPSettings{ @@ -164,7 +168,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL settings.BlackListedLabels = make([]portainer.Pair, 0) } - return settingsService.StoreSettings(settings) + return settingsService.UpdateSettings(settings) } else if err != nil { return err } @@ -232,6 +236,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } if strings.HasPrefix(endpoint.URL, "tcp://") { @@ -270,6 +275,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } return endpointService.CreateEndpoint(endpoint) @@ -301,7 +307,7 @@ func main() { fileService := initFileService(*flags.Data) - store := initStore(*flags.Data) + store := initStore(*flags.Data, fileService) defer store.Close() jwtService := initJWTService(!*flags.NoAuth) @@ -321,11 +327,13 @@ func main() { log.Fatal(err) } - stackManager, err := initStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) + swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) if err != nil { log.Fatal(err) } + composeStackManager := initComposeStackManager(*flags.Data) + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) @@ -395,7 +403,9 @@ func main() { RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, - StackManager: stackManager, + TagService: store.TagService, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, diff --git a/api/errors.go b/api/errors.go index 7cd39f445..9e4eb70e7 100644 --- a/api/errors.go +++ b/api/errors.go @@ -4,63 +4,64 @@ package portainer const ( ErrUnauthorized = Error("Unauthorized") ErrResourceAccessDenied = Error("Access denied to resource") - ErrResourceNotFound = Error("Unable to find resource") - ErrUnsupportedDockerAPI = Error("Unsupported Docker API response") + ErrObjectNotFound = Error("Object not found inside the database") ErrMissingSecurityContext = Error("Unable to find security details in request context") ) // User errors. const ( - ErrUserNotFound = Error("User not found") ErrUserAlreadyExists = Error("User already exists") ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") ErrAdminAlreadyInitialized = Error("An administrator user already exists") - ErrCannotRemoveAdmin = Error("Cannot remove the default administrator account") ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") ) // Team errors. const ( - ErrTeamNotFound = Error("Team not found") ErrTeamAlreadyExists = Error("Team already exists") ) // TeamMembership errors. const ( - ErrTeamMembershipNotFound = Error("Team membership not found") ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team") ) // ResourceControl errors. const ( - ErrResourceControlNotFound = Error("Resource control not found") ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource") ErrInvalidResourceControlType = Error("Unsupported resource control type") ) // Endpoint errors. const ( - ErrEndpointNotFound = Error("Endpoint not found") ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Azure environment errors +const ( + ErrAzureInvalidCredentials = Error("Invalid Azure credentials") +) + // Endpoint group errors. const ( - ErrEndpointGroupNotFound = Error("Endpoint group not found") ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") ) // Registry errors. const ( - ErrRegistryNotFound = Error("Registry not found") 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") + ErrStackNotExternal = Error("Not an external stack") +) + +// Tag errors +const ( + ErrTagAlreadyExists = Error("A tag already exists with this name") ) // Endpoint extensions error @@ -69,21 +70,6 @@ const ( ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint") ) -// Version errors. -const ( - ErrDBVersionNotFound = Error("DB version not found") -) - -// Settings errors. -const ( - ErrSettingsNotFound = Error("Settings not found") -) - -// DockerHub errors. -const ( - ErrDockerHubNotFound = Error("Dockerhub not found") -) - // Crypto errors. const ( ErrCryptoHashFailure = Error("Unable to hash data") diff --git a/api/exec/stack_manager.go b/api/exec/swarm_stack.go similarity index 82% rename from api/exec/stack_manager.go rename to api/exec/swarm_stack.go index d8c2d9438..1e896971e 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/swarm_stack.go @@ -11,18 +11,18 @@ import ( "github.com/portainer/portainer" ) -// StackManager represents a service for managing stacks. -type StackManager struct { +// SwarmStackManager represents a service for managing stacks. +type SwarmStackManager struct { binaryPath string dataPath string signatureService portainer.DigitalSignatureService fileService portainer.FileService } -// NewStackManager initializes a new StackManager service. +// NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) { - manager := &StackManager{ +func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*SwarmStackManager, error) { + manager := &SwarmStackManager{ binaryPath: binaryPath, dataPath: dataPath, signatureService: signatureService, @@ -38,7 +38,7 @@ func NewStackManager(binaryPath, dataPath string, signatureService portainer.Dig } // Login executes the docker login command against a list of registries (including DockerHub). -func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { +func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { @@ -54,14 +54,14 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] } // Logout executes the docker logout command. -func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "logout") return runCommandAndCaptureStdErr(command, args, nil, "") } // Deploy executes the docker stack deploy command. -func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) @@ -81,7 +81,7 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint } // Remove executes the docker stack rm command. -func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "stack", "rm", stack.Name) return runCommandAndCaptureStdErr(command, args, nil, "") @@ -133,7 +133,7 @@ func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portaine return command, args } -func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error { +func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) error { configFilePath := path.Join(dataPath, "config.json") config, err := manager.retrieveConfigurationFromDisk(configFilePath) if err != nil { @@ -161,7 +161,7 @@ func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error return nil } -func (manager *StackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { +func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { var config map[string]interface{} raw, err := manager.fileService.GetFileContent(path) diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 770b8af76..5bff04852 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -77,9 +77,9 @@ 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. +// StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. -func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) { +func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { stackStorePath := path.Join(ComposeStorePath, stackIdentifier) err := service.createDirectoryInStore(stackStorePath) if err != nil { @@ -87,7 +87,6 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac } composeFilePath := path.Join(stackStorePath, fileName) - data := []byte(stackFileContent) r := bytes.NewReader(data) err = service.createFileInStore(composeFilePath, r) @@ -98,31 +97,13 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac 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, fileName string, r io.Reader) (string, error) { - stackStorePath := path.Join(ComposeStorePath, stackIdentifier) - err := service.createDirectoryInStore(stackStorePath) - if err != nil { - return "", err - } - - composeFilePath := path.Join(stackStorePath, fileName) - - 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 { +// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. +// It returns the path to the newly created file. +func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { storePath := path.Join(TLSStorePath, folder) err := service.createDirectoryInStore(storePath) if err != nil { - return err + return "", err } var fileName string @@ -134,15 +115,16 @@ func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileTy case portainer.TLSFileKey: fileName = TLSKeyFile default: - return portainer.ErrUndefinedTLSFileType + return "", portainer.ErrUndefinedTLSFileType } tlsFilePath := path.Join(storePath, fileName) + r := bytes.NewReader(data) err = service.createFileInStore(tlsFilePath, r) if err != nil { - return err + return "", err } - return nil + return path.Join(service.fileStorePath, tlsFilePath), nil } // GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. @@ -204,6 +186,11 @@ func (service *Service) GetFileContent(filePath string) (string, error) { return string(content), nil } +// Rename renames a file or directory +func (service *Service) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} + // WriteJSONToFile writes JSON to the specified file. func (service *Service) WriteJSONToFile(path string, content interface{}) error { jsonContent, err := json.Marshal(content) @@ -214,10 +201,21 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error return ioutil.WriteFile(path, jsonContent, 0644) } +// FileExists checks for the existence of the specified file. +func (service *Service) FileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + // KeyPairFilesExist checks for the existence of the key files. func (service *Service) KeyPairFilesExist() (bool, error) { privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile) - exists, err := fileExists(privateKeyPath) + exists, err := service.FileExists(privateKeyPath) if err != nil { return false, err } @@ -226,7 +224,7 @@ func (service *Service) KeyPairFilesExist() (bool, error) { } publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile) - exists, err = fileExists(publicKeyPath) + exists, err = service.FileExists(publicKeyPath) if err != nil { return false, err } @@ -320,13 +318,3 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { block, _ := pem.Decode(fileContent) return block.Bytes, nil } - -func fileExists(filePath string) (bool, error) { - if _, err := os.Stat(filePath); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} diff --git a/api/http/client/client.go b/api/http/client/client.go index 438be12ad..338aed15d 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -2,15 +2,68 @@ package client import ( "crypto/tls" + "encoding/json" + "fmt" "net/http" + "net/url" "strings" "time" "github.com/portainer/portainer" ) +// HTTPClient represents a client to send HTTP requests. +type HTTPClient struct { + *http.Client +} + +// NewHTTPClient is used to build a new HTTPClient. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + &http.Client{ + Timeout: time.Second * 5, + }, + } +} + +// AzureAuthenticationResponse represents an Azure API authentication response. +type AzureAuthenticationResponse struct { + AccessToken string `json:"access_token"` + ExpiresOn string `json:"expires_on"` +} + +// ExecuteAzureAuthenticationRequest is used to execute an authentication request +// against the Azure API. It re-uses the same http.Client. +func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portainer.AzureCredentials) (*AzureAuthenticationResponse, error) { + loginURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", credentials.TenantID) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {credentials.ApplicationID}, + "client_secret": {credentials.AuthenticationKey}, + "resource": {"https://management.azure.com/"}, + } + + response, err := client.PostForm(loginURL, params) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, portainer.ErrAzureInvalidCredentials + } + + var token AzureAuthenticationResponse + err = json.NewDecoder(response.Body).Decode(&token) + if err != nil { + return nil, err + } + + return &token, nil +} + // ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment // using the specified host and optional TLS configuration. +// It uses a new Http.Client for each operation. func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { transport := &http.Transport{} diff --git a/api/http/error/error.go b/api/http/error/error.go index 7a2715c8a..b9153a8a6 100644 --- a/api/http/error/error.go +++ b/api/http/error/error.go @@ -6,18 +6,36 @@ import ( "net/http" ) -// errorResponse is a generic response for sending a error. -type errorResponse struct { - Err string `json:"err,omitempty"` -} - -// WriteErrorResponse writes an error message to the response and logger. -func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) { - if logger != nil { - logger.Printf("http error: %s (code=%d)", err, code) +type ( + // LoggerHandler defines a HTTP handler that includes a HandlerError return pointer + LoggerHandler func(http.ResponseWriter, *http.Request) *HandlerError + // HandlerError represents an error raised inside a HTTP handler + HandlerError struct { + StatusCode int + Message string + Err error } + errorResponse struct { + Err string `json:"err,omitempty"` + } +) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +func (handler LoggerHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + err := handler(rw, r) + if err != nil { + writeErrorResponse(rw, err) + } +} + +func writeErrorResponse(rw http.ResponseWriter, err *HandlerError) { + log.Printf("http error: %s (err=%s) (code=%d)\n", err.Message, err.Err, err.StatusCode) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(err.StatusCode) + json.NewEncoder(rw).Encode(&errorResponse{Err: err.Message}) +} + +// WriteError is a convenience function that creates a new HandlerError before calling writeErrorResponse. +// For use outside of the standard http handlers. +func WriteError(rw http.ResponseWriter, code int, message string, err error) { + writeErrorResponse(rw, &HandlerError{code, message, err}) } diff --git a/api/http/handler/auth.go b/api/http/handler/auth.go deleted file mode 100644 index 4b75967e9..000000000 --- a/api/http/handler/auth.go +++ /dev/null @@ -1,126 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// AuthHandler represents an HTTP API handler for managing authentication. -type AuthHandler struct { - *mux.Router - Logger *log.Logger - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SettingsService portainer.SettingsService -} - -const ( - // ErrInvalidCredentialsFormat is an error raised when credentials format is not valid - ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format") - // ErrInvalidCredentials is an error raised when credentials for a user are invalid - ErrInvalidCredentials = portainer.Error("Invalid credentials") - // ErrAuthDisabled is an error raised when trying to access the authentication endpoints - // when the server has been started with the --no-auth flag - ErrAuthDisabled = portainer.Error("Authentication is disabled") -) - -// NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler { - h := &AuthHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authDisabled: authDisabled, - } - h.Handle("/auth", - rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost) - - return h -} - -type ( - postAuthRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } - - postAuthResponse struct { - JWT string `json:"jwt"` - } -) - -func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { - if handler.authDisabled { - httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - var req postAuthRequest - 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, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) - return - } - - var username = req.Username - var password = req.Password - - u, err := handler.UserService.UserByUsername(username) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { - err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) - return - } - } - - tokenData := &portainer.TokenData{ - ID: u.ID, - Username: u.Username, - Role: u.Role, - } - - token, err := handler.JWTService.GenerateToken(tokenData) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go new file mode 100644 index 000000000..b146352a8 --- /dev/null +++ b/api/http/handler/auth/authenticate.go @@ -0,0 +1,79 @@ +package auth + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type authenticatePayload struct { + Username string + Password string +} + +type authenticateResponse struct { + JWT string `json:"jwt"` +} + +func (payload *authenticatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) { + return portainer.Error("Invalid username") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if handler.authDisabled { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} + } + + var payload authenticatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + u, err := handler.UserService.UserByUsername(payload.Username) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { + err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate user via LDAP/AD", err} + } + } else { + err = handler.CryptoService.CompareHashAndData(u.Password, payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", ErrInvalidCredentials} + } + } + + tokenData := &portainer.TokenData{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + } + + token, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + + return response.JSON(w, &authenticateResponse{JWT: token}) +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go new file mode 100644 index 000000000..db47b82e2 --- /dev/null +++ b/api/http/handler/auth/handler.go @@ -0,0 +1,41 @@ +package auth + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + // ErrInvalidCredentials is an error raised when credentials for a user are invalid + ErrInvalidCredentials = portainer.Error("Invalid credentials") + // ErrAuthDisabled is an error raised when trying to access the authentication endpoints + // when the server has been started with the --no-auth flag + ErrAuthDisabled = portainer.Error("Authentication is disabled") +) + +// Handler is the HTTP handler used to handle authentication operations. +type Handler struct { + *mux.Router + authDisabled bool + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage authentication operations. +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authDisabled: authDisabled, + } + h.Handle("/auth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go deleted file mode 100644 index 24cf0831d..000000000 --- a/api/http/handler/docker.go +++ /dev/null @@ -1,92 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - 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" -) - -// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. -type DockerHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { - h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/docker").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) - return h -} - -func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go deleted file mode 100644 index 75acc0517..000000000 --- a/api/http/handler/dockerhub.go +++ /dev/null @@ -1,91 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHubHandler represents an HTTP API handler for managing DockerHub. -type DockerHubHandler struct { - *mux.Router - Logger *log.Logger - DockerHubService portainer.DockerHubService -} - -// NewDockerHubHandler returns a new instance of DockerHubHandler. -func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { - h := &DockerHubHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/dockerhub", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) - h.Handle("/dockerhub", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut) - - return h -} - -type ( - putDockerHubRequest struct { - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetDockerHub handles GET requests on /dockerhub -func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) { - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub.Password = "" - - encodeJSON(w, dockerhub, handler.Logger) - return -} - -// handlePutDockerHub handles PUT requests on /dockerhub -func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) { - var req putDockerHubRequest - 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 - } - - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - if req.Authentication { - dockerhub.Authentication = true - dockerhub.Username = req.Username - dockerhub.Password = req.Password - } - - err = handler.DockerHubService.StoreDockerHub(dockerhub) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go new file mode 100644 index 000000000..25be6617c --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_inspect.go @@ -0,0 +1,19 @@ +package dockerhub + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/dockerhub +func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + hideFields(dockerhub) + return response.JSON(w, dockerhub) +} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go new file mode 100644 index 000000000..7bd37bce5 --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -0,0 +1,52 @@ +package dockerhub + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type dockerhubUpdatePayload struct { + Authentication bool + Username string + Password string +} + +func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/dockerhub +func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload dockerhubUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + dockerhub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + + if payload.Authentication { + dockerhub.Authentication = true + dockerhub.Username = payload.Username + dockerhub.Password = payload.Password + } + + err = handler.DockerHubService.UpdateDockerHub(dockerhub) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go new file mode 100644 index 000000000..cd2f5ae50 --- /dev/null +++ b/api/http/handler/dockerhub/handler.go @@ -0,0 +1,33 @@ +package dockerhub + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +func hideFields(dockerHub *portainer.DockerHub) { + dockerHub.Password = "" +} + +// Handler is the HTTP handler used to handle DockerHub operations. +type Handler struct { + *mux.Router + DockerHubService portainer.DockerHubService +} + +// NewHandler creates a handler to manage Dockerhub operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/dockerhub", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) + h.Handle("/dockerhub", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go deleted file mode 100644 index 23c867303..000000000 --- a/api/http/handler/endpoint.go +++ /dev/null @@ -1,541 +0,0 @@ -package handler - -import ( - "bytes" - "strings" - - "github.com/portainer/portainer" - "github.com/portainer/portainer/crypto" - "github.com/portainer/portainer/http/client" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointHandler represents an HTTP API handler for managing Docker endpoints. -type EndpointHandler struct { - *mux.Router - Logger *log.Logger - authorizeEndpointManagement bool - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - FileService portainer.FileService - ProxyManager *proxy.Manager -} - -const ( - // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints - // when the server has been started with the --external-endpoints flag - ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") -) - -// NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler { - h := &EndpointHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authorizeEndpointManagement: authorizeEndpointManagement, - } - h.Handle("/endpoints", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) - h.Handle("/endpoints", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) - - return h -} - -type ( - putEndpointAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - GroupID int `valid:"-"` - TLS bool `valid:"-"` - TLSSkipVerify bool `valid:"-"` - TLSSkipClientVerify bool `valid:"-"` - } - - postEndpointPayload struct { - name string - url string - publicURL string - groupID int - useTLS bool - skipTLSServerVerification bool - skipTLSClientVerification bool - caCert []byte - cert []byte - key []byte - } -) - -// handleGetEndpoints handles GET requests on /endpoints -func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - groups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, filteredEndpoints, handler.Logger) -} - -func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) - if err != nil { - return nil, err - } - - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig) - if err != nil { - return nil, err - } - - endpointType := portainer.DockerEnvironment - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: payload.useTLS, - TLSSkipVerify: payload.skipTLSServerVerification, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - folder := strconv.Itoa(int(endpoint.ID)) - - if !payload.skipTLSServerVerification { - r := bytes.NewReader(payload.caCert) - // TODO: review the API exposed by the FileService to store - // a file from a byte slice and return the path to the stored file instead - // of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here. - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } - - if !payload.skipTLSClientVerification { - r := bytes.NewReader(payload.cert) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - - r = bytes.NewReader(payload.key) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - endpointType := portainer.DockerEnvironment - - if !strings.HasPrefix(payload.url, "unix://") { - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil) - if err != nil { - return nil, err - } - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: false, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err := handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - if payload.useTLS { - return handler.createTLSSecuredEndpoint(payload) - } - return handler.createUnsecuredEndpoint(payload) -} - -func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { - payload := &postEndpointPayload{} - payload.name = r.FormValue("Name") - payload.url = r.FormValue("URL") - payload.publicURL = r.FormValue("PublicURL") - - if payload.name == "" || payload.url == "" { - return nil, ErrInvalidRequestFormat - } - - rawGroupID := r.FormValue("GroupID") - if rawGroupID == "" { - payload.groupID = 1 - } else { - groupID, err := strconv.Atoi(rawGroupID) - if err != nil { - return nil, err - } - payload.groupID = groupID - } - - payload.useTLS = r.FormValue("TLS") == "true" - - if payload.useTLS { - payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true" - payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true" - - if !payload.skipTLSServerVerification { - caCert, err := getUploadedFileContent(r, "TLSCACertFile") - if err != nil { - return nil, err - } - payload.caCert = caCert - } - - if !payload.skipTLSClientVerification { - cert, err := getUploadedFileContent(r, "TLSCertFile") - if err != nil { - return nil, err - } - payload.cert = cert - key, err := getUploadedFileContent(r, "TLSKeyFile") - if err != nil { - return nil, err - } - payload.key = key - } - } - - return payload, nil -} - -// handlePostEndpoints handles POST requests on /endpoints -func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - payload, err := convertPostEndpointRequestToPayload(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.createEndpoint(payload) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &endpoint, handler.Logger) -} - -// handleGetEndpoint handles GET requests on /endpoints/:id -func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - 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 - } - - encodeJSON(w, endpoint, handler.Logger) -} - -// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access -func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointAccessRequest - 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 - } - - 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 - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpoint.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpoint.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpoint handles PUT requests on /endpoints/:id -func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointsRequest - 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 - } - - 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 - } - - if req.Name != "" { - endpoint.Name = req.Name - } - - if req.URL != "" { - endpoint.URL = req.URL - } - - if req.PublicURL != "" { - endpoint.PublicURL = req.PublicURL - } - - if req.GroupID != 0 { - endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) - } - - folder := strconv.Itoa(int(endpoint.ID)) - if req.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify - if !req.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } else { - endpoint.TLSConfig.TLSCACertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) - } - - if !req.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } else { - endpoint.TLSConfig.TLSCertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSKeyPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) - } - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(folder) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteEndpoint handles DELETE requests on /endpoints/:id -func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - 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 - } - - handler.ProxyManager.DeleteProxy(string(endpointID)) - handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) - - err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if endpoint.TLSConfig.TLS { - err = handler.FileService.DeleteTLSFiles(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} diff --git a/api/http/handler/endpoint_group.go b/api/http/handler/endpoint_group.go deleted file mode 100644 index 064f0dff1..000000000 --- a/api/http/handler/endpoint_group.go +++ /dev/null @@ -1,364 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups. -type EndpointGroupHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService -} - -// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler. -func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler { - h := &EndpointGroupHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/endpoint_groups", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost) - h.Handle("/endpoint_groups", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete) - - return h -} - -type ( - postEndpointGroupsResponse struct { - ID int `json:"Id"` - } - - postEndpointGroupsRequest struct { - Name string `valid:"required"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } - - putEndpointGroupAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointGroupsRequest struct { - Name string `valid:"-"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } -) - -// handleGetEndpointGroups handles GET requests on /endpoint_groups -func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, filteredEndpointGroups, handler.Logger) -} - -// handlePostEndpointGroups handles POST requests on /endpoint_groups -func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) { - var req postEndpointGroupsRequest - 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 - } - - endpointGroup := &portainer.EndpointGroup{ - Name: req.Name, - Description: req.Description, - Labels: req.Labels, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == portainer.EndpointGroupID(1) { - err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } - - encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger) -} - -// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, endpointGroup, handler.Logger) -} - -// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access -func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupAccessRequest - 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 - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpointGroup.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpointGroup.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupsRequest - 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 - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - endpointGroup.Name = req.Name - } - - if req.Description != "" { - endpointGroup.Description = req.Description - } - - endpointGroup.Labels = req.Labels - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} - -func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - if endpoint.GroupID == groupID { - return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) - } else if endpoint.GroupID == portainer.EndpointGroupID(1) { - return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) - } - return nil -} - -func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - if id == endpoint.ID { - return nil - } - } - - endpoint.GroupID = portainer.EndpointGroupID(1) - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) -} - -func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - - if id == endpoint.ID { - endpoint.GroupID = groupID - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - } - } - return nil -} - -// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - if endpointGroupID == 1 { - httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger) - return - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - _, err = handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == groupID { - endpoint.GroupID = portainer.EndpointGroupID(1) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } -} diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go new file mode 100644 index 000000000..e2d7bd0c0 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -0,0 +1,66 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupCreatePayload struct { + Name string + Description string + AssociatedEndpoints []portainer.EndpointID + Tags []string +} + +func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid endpoint group name") + } + if payload.Tags == nil { + payload.Tags = []string{} + } + return nil +} + +// POST request on /api/endpoint_groups +func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload endpointGroupCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup := &portainer.EndpointGroup{ + Name: payload.Name, + Description: payload.Description, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Tags: payload.Tags, + } + + err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the endpoint group inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(1) { + err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go new file mode 100644 index 000000000..01123d850 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -0,0 +1,51 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + if endpointGroupID == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} + } + + _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the endpoint group from the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go new file mode 100644 index 000000000..168d8cb8b --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -0,0 +1,27 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go new file mode 100644 index 000000000..fa7a35ec4 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -0,0 +1,25 @@ +package endpointgroups + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoint_groups +func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext) + return response.JSON(w, endpointGroups) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go new file mode 100644 index 000000000..ee31e3066 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -0,0 +1,73 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdatePayload struct { + Name string + Description string + AssociatedEndpoints []portainer.EndpointID + Tags []string +} + +func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpointGroup.Name = payload.Name + } + + if payload.Description != "" { + endpointGroup.Description = payload.Description + } + + if payload.Tags != nil { + endpointGroup.Tags = payload.Tags + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + err = handler.updateEndpointGroup(endpoint, portainer.EndpointGroupID(endpointGroupID), payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update_access.go b/api/http/handler/endpointgroups/endpointgroup_update_access.go new file mode 100644 index 000000000..7a3b3038e --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update_access.go @@ -0,0 +1,63 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointGroupUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id/access +func (handler *Handler) endpointGroupUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpointGroup.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpointGroup.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go new file mode 100644 index 000000000..f31a40a27 --- /dev/null +++ b/api/http/handler/endpointgroups/handler.go @@ -0,0 +1,69 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/endpoint_groups", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + + return h +} + +func (handler *Handler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + if id == endpoint.ID { + return nil + } + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) +} + +func (handler *Handler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + + if id == endpoint.ID { + endpoint.GroupID = groupID + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + } + } + return nil +} + +func (handler *Handler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + if endpoint.GroupID == groupID { + return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) + } else if endpoint.GroupID == portainer.EndpointGroupID(1) { + return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) + } + return nil +} diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go new file mode 100644 index 000000000..cd17e0733 --- /dev/null +++ b/api/http/handler/endpointproxy/handler.go @@ -0,0 +1,32 @@ +package endpointproxy + +import ( + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to proxy requests to external APIs. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + EndpointService portainer.EndpointService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to proxy requests to external APIs. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) + h.PathPrefix("/{id}/docker").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/extensions/storidge").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) + return h +} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 000000000..dc46bfb3f --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,43 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + + "net/http" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go new file mode 100644 index 000000000..01a56e017 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -0,0 +1,43 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + + "net/http" +) + +func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go new file mode 100644 index 000000000..a582b561d --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -0,0 +1,56 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + + "net/http" +) + +func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + var storidgeExtension *portainer.EndpointExtension + for _, extension := range endpoint.Extensions { + if extension.Type == portainer.StoridgeEndpointExtension { + storidgeExtension = &extension + } + } + + if storidgeExtension == nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", portainer.ErrEndpointExtensionNotSupported} + } + + proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go new file mode 100644 index 000000000..7827068cb --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create.go @@ -0,0 +1,306 @@ +package endpoints + +import ( + "net/http" + "strconv" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointCreatePayload struct { + Name string + URL string + EndpointType int + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string + Tags []string +} + +func (payload *endpointCreatePayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) + if err != nil || endpointType == 0 { + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)") + } + payload.EndpointType = endpointType + + groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true) + if groupID == 0 { + groupID = 1 + } + payload.GroupID = groupID + + var tags []string + err = request.RetrieveMultiPartFormJSONValue(r, "Tags", &tags, true) + if err != nil { + return portainer.Error("Invalid Tags parameter") + } + payload.Tags = tags + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + if payload.TLS { + skipTLSServerVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSServerVerification + skipTLSClientVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipClientVerify", true) + payload.TLSSkipClientVerify = skipTLSClientVerification + + if !payload.TLSSkipVerify { + caCert, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCACertFile = caCert + } + + if !payload.TLSSkipClientVerify { + cert, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCertFile = cert + + key, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err != nil { + return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + } + payload.TLSKeyFile = key + } + } + + switch portainer.EndpointType(payload.EndpointType) { + case portainer.AzureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return portainer.Error("Invalid Azure application ID") + } + payload.AzureApplicationID = azureApplicationID + + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return portainer.Error("Invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return portainer.Error("Invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + default: + url, err := request.RetrieveMultiPartFormValue(r, "URL", false) + if err != nil { + return portainer.Error("Invalid endpoint URL") + } + payload.URL = url + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } + + return nil +} + +// POST request on /api/endpoints +func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + payload := &endpointCreatePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, endpointCreationError := handler.createEndpoint(payload) + if endpointCreationError != nil { + return endpointCreationError + } + + return response.JSON(w, endpoint) +} + +func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } + + if payload.TLS { + return handler.createTLSSecuredEndpoint(payload) + } + return handler.createUnsecuredEndpoint(payload) +} + +func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + Tags: payload.Tags, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointType := portainer.DockerEnvironment + + if !strings.HasPrefix(payload.URL, "unix://") { + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, + } + + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipClientVerify, payload.TLSSkipVerify) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to create TLS configuration", err} + } + + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, tlsConfig) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + + endpointType := portainer.DockerEnvironment + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + filesystemError := handler.storeTLSFiles(endpoint, payload) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, filesystemError + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError { + folder := strconv.Itoa(int(endpoint.ID)) + + if !payload.TLSSkipVerify { + caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err} + } + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if !payload.TLSSkipClientVerify { + certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err} + } + endpoint.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err} + } + endpoint.TLSConfig.TLSKeyPath = keyPath + } + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go new file mode 100644 index 000000000..40f18f348 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -0,0 +1,48 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id +func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if endpoint.TLSConfig.TLS { + folder := strconv.Itoa(endpointID) + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} + } + + handler.ProxyManager.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go new file mode 100644 index 000000000..009083190 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -0,0 +1,73 @@ +package endpoints + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointExtensionAddPayload struct { + Type int + URL string +} + +func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { + if payload.Type != 1 { + return portainer.Error("Invalid type value. Value must be one of: 1 (Storidge)") + } + if payload.Type == 1 && govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid extension URL") + } + return nil +} + +// POST request on /api/endpoints/:id/extensions +func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + var payload endpointExtensionAddPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionType := portainer.EndpointExtensionType(payload.Type) + + var extension *portainer.EndpointExtension + for _, ext := range endpoint.Extensions { + if ext.Type == extensionType { + extension = &ext + } + } + + if extension != nil { + extension.URL = payload.URL + } else { + extension = &portainer.EndpointExtension{ + Type: extensionType, + URL: payload.URL, + } + endpoint.Extensions = append(endpoint.Extensions, *extension) + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, extension) +} diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go new file mode 100644 index 000000000..2e238a89e --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -0,0 +1,43 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id/extensions/:extensionType +func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + extensionType, err := request.RetrieveNumericRouteVariableValue(r, "extensionType") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension type route variable", err} + } + + for idx, ext := range endpoint.Extensions { + if ext.Type == portainer.EndpointExtensionType(extensionType) { + endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) + } + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go new file mode 100644 index 000000000..82d2cb7b9 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -0,0 +1,27 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoints/:id +func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go new file mode 100644 index 000000000..13268b48d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list.go @@ -0,0 +1,34 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoints +func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + for _, endpoint := range filteredEndpoints { + hideFields(&endpoint) + } + return response.JSON(w, filteredEndpoints) +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go new file mode 100644 index 000000000..769267449 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update.go @@ -0,0 +1,142 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdatePayload struct { + Name string + URL string + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string + Tags []string +} + +func (payload *endpointUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id +func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpoint.Name = payload.Name + } + + if payload.URL != "" { + endpoint.URL = payload.URL + } + + if payload.PublicURL != "" { + endpoint.PublicURL = payload.PublicURL + } + + if payload.GroupID != 0 { + endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) + } + + if payload.Tags != nil { + endpoint.Tags = payload.Tags + } + + if endpoint.Type == portainer.AzureEnvironment { + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != "" { + credentials.ApplicationID = payload.AzureApplicationID + } + if payload.AzureTenantID != "" { + credentials.TenantID = payload.AzureTenantID + } + if payload.AzureAuthenticationKey != "" { + credentials.AuthenticationKey = payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + _, authErr := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if authErr != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", authErr} + } + endpoint.AzureCredentials = credentials + } + + folder := strconv.Itoa(endpointID) + if payload.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify + if !payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + + if !payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } + } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_update_access.go b/api/http/handler/endpoints/endpoint_update_access.go new file mode 100644 index 000000000..bedf559ff --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_access.go @@ -0,0 +1,67 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id/access +func (handler *Handler) endpointUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpoint.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go new file mode 100644 index 000000000..6a593479c --- /dev/null +++ b/api/http/handler/endpoints/handler.go @@ -0,0 +1,59 @@ +package endpoints + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +const ( + // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints + // when the server has been started with the --external-endpoints flag + ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") +) + +func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} +} + +// Handler is the HTTP handler used to handle endpoint operations. +type Handler struct { + *mux.Router + authorizeEndpointManagement bool + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authorizeEndpointManagement: authorizeEndpointManagement, + } + + h.Handle("/endpoints", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + h.Handle("/endpoints", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/{id}/extensions", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/extensions/{extensionType}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/extensions.go b/api/http/handler/extensions.go deleted file mode 100644 index ecae4678e..000000000 --- a/api/http/handler/extensions.go +++ /dev/null @@ -1,143 +0,0 @@ -package handler - -import ( - "encoding/json" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - 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" -) - -// ExtensionHandler represents an HTTP API handler for managing Settings. -type ExtensionHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - ProxyManager *proxy.Manager -} - -// NewExtensionHandler returns a new instance of ExtensionHandler. -func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler { - h := &ExtensionHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/{endpointId}/extensions", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost) - h.Handle("/{endpointId}/extensions/{extensionType}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleDeleteExtensions))).Methods(http.MethodDelete) - return h -} - -type ( - postExtensionRequest struct { - Type int `valid:"required"` - URL string `valid:"required"` - } -) - -func (handler *ExtensionHandler) handlePostExtensions(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 postExtensionRequest - 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 - } - - extensionType := portainer.EndpointExtensionType(req.Type) - - var extension *portainer.EndpointExtension - - for _, ext := range endpoint.Extensions { - if ext.Type == extensionType { - extension = &ext - } - } - - if extension != nil { - extension.URL = req.URL - } else { - extension = &portainer.EndpointExtension{ - Type: extensionType, - URL: req.URL, - } - endpoint.Extensions = append(endpoint.Extensions, *extension) - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, extension, handler.Logger) -} - -func (handler *ExtensionHandler) handleDeleteExtensions(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 - } - - extType, err := strconv.Atoi(vars["extensionType"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - extensionType := portainer.EndpointExtensionType(extType) - - for idx, ext := range endpoint.Extensions { - if ext.Type == extensionType { - endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) - } - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go deleted file mode 100644 index bee1cd7b3..000000000 --- a/api/http/handler/extensions/storidge.go +++ /dev/null @@ -1,106 +0,0 @@ -package extensions - -import ( - "strconv" - - "github.com/portainer/portainer" - 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" -) - -// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API. -type StoridgeHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewStoridgeHandler returns a new instance of StoridgeHandler. -func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler { - h := &StoridgeHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/extensions/storidge").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI))) - return h -} - -func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - var storidgeExtension *portainer.EndpointExtension - for _, extension := range endpoint.Extensions { - if extension.Type == portainer.StoridgeEndpointExtension { - storidgeExtension = &extension - } - } - - if storidgeExtension == nil { - httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger) - return - } - - proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) - - var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/file.go b/api/http/handler/file/handler.go similarity index 54% rename from api/http/handler/file.go rename to api/http/handler/file/handler.go index efc7e2b77..15ec1417f 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file/handler.go @@ -1,24 +1,19 @@ -package handler +package file import ( - "os" - - "log" "net/http" "strings" ) -// FileHandler represents an HTTP API handler for managing static files. -type FileHandler struct { +// Handler represents an HTTP API handler for managing static files. +type Handler struct { http.Handler - Logger *log.Logger } -// NewFileHandler returns a new instance of FileHandler. -func NewFileHandler(assetPublicPath string) *FileHandler { - h := &FileHandler{ +// NewHandler creates a handler to serve static files. +func NewHandler(assetPublicPath string) *Handler { + h := &Handler{ Handler: http.FileServer(http.Dir(assetPublicPath)), - Logger: log.New(os.Stderr, "", log.LstdFlags), } return h } @@ -32,7 +27,7 @@ func isHTML(acceptContent []string) bool { return false } -func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 77c6ce478..dd15e1212 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -1,52 +1,54 @@ package handler import ( - "encoding/json" - "io/ioutil" - "log" "net/http" "strings" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" ) // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *AuthHandler - UserHandler *UserHandler - TeamHandler *TeamHandler - TeamMembershipHandler *TeamMembershipHandler - EndpointHandler *EndpointHandler - EndpointGroupHandler *EndpointGroupHandler - RegistryHandler *RegistryHandler - DockerHubHandler *DockerHubHandler - ExtensionHandler *ExtensionHandler - StoridgeHandler *extensions.StoridgeHandler - ResourceHandler *ResourceHandler - StackHandler *StackHandler - StatusHandler *StatusHandler - SettingsHandler *SettingsHandler - TemplatesHandler *TemplatesHandler - DockerHandler *DockerHandler - WebSocketHandler *WebSocketHandler - UploadHandler *UploadHandler - FileHandler *FileHandler -} + AuthHandler *auth.Handler -const ( - // ErrInvalidJSON defines an error raised the app is unable to parse request data - ErrInvalidJSON = portainer.Error("Invalid JSON") - // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid - ErrInvalidRequestFormat = portainer.Error("Invalid request data format") - // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid - ErrInvalidQueryFormat = portainer.Error("Invalid query format") -) + DockerHubHandler *dockerhub.Handler + EndpointGroupHandler *endpointgroups.Handler + EndpointHandler *endpoints.Handler + EndpointProxyHandler *endpointproxy.Handler + FileHandler *file.Handler + RegistryHandler *registries.Handler + ResourceControlHandler *resourcecontrols.Handler + SettingsHandler *settings.Handler + StackHandler *stacks.Handler + StatusHandler *status.Handler + TagHandler *tags.Handler + TeamMembershipHandler *teammemberships.Handler + TeamHandler *teams.Handler + TemplatesHandler *templates.Handler + UploadHandler *upload.Handler + UserHandler *users.Handler + WebSocketHandler *websocket.Handler +} // ServeHTTP delegates a request to the appropriate subhandler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) @@ -57,24 +59,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): - http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) - case strings.Contains(r.URL.Path, "/stacks"): - http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/extensions/storidge"): - http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r) - case strings.Contains(r.URL.Path, "/extensions"): - http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/azure/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } case strings.HasPrefix(r.URL.Path, "/api/registries"): http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): - http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) + http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/settings"): http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/stacks"): + http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/tags"): + http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/upload"): @@ -91,27 +95,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.FileHandler.ServeHTTP(w, r) } } - -// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails. -func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) - } -} - -// getUploadedFileContent retrieve the content of a file uploaded in the request. -// Uses requestParameter as the key to retrieve the file in the request payload. -func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) { - file, _, err := request.FormFile(requestParameter) - if err != nil { - return nil, err - } - defer file.Close() - - fileContent, err := ioutil.ReadAll(file) - if err != nil { - return nil, err - } - return fileContent, nil -} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go new file mode 100644 index 000000000..7a21561db --- /dev/null +++ b/api/http/handler/registries/handler.go @@ -0,0 +1,43 @@ +package registries + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(registry *portainer.Registry) { + registry.Password = "" +} + +// Handler is the HTTP handler used to handle registry operations. +type Handler struct { + *mux.Router + RegistryService portainer.RegistryService +} + +// NewHandler creates a handler to manage registry operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/registries", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) + h.Handle("/registries", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) + h.Handle("/registries/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go new file mode 100644 index 000000000..781f172bf --- /dev/null +++ b/api/http/handler/registries/registry_create.go @@ -0,0 +1,68 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryCreatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid registry name") + } + if govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid registry URL") + } + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload registryCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL { + return &httperror.HandlerError{http.StatusConflict, "A registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + registry := &portainer.Registry{ + Name: payload.Name, + URL: payload.URL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.RegistryService.CreateRegistry(registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go new file mode 100644 index 000000000..2e968539b --- /dev/null +++ b/api/http/handler/registries/registry_delete.go @@ -0,0 +1,32 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/registries/:id +func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go new file mode 100644 index 000000000..a60f24288 --- /dev/null +++ b/api/http/handler/registries/registry_inspect.go @@ -0,0 +1,28 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/registries/:id +func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go new file mode 100644 index 000000000..158f8e3fe --- /dev/null +++ b/api/http/handler/registries/registry_list.go @@ -0,0 +1,29 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/registries +func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredRegistries := security.FilterRegistries(registries, securityContext) + + for _, registry := range filteredRegistries { + hideFields(®istry) + } + return response.JSON(w, registries) +} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go new file mode 100644 index 000000000..1a3743fb5 --- /dev/null +++ b/api/http/handler/registries/registry_update.go @@ -0,0 +1,82 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/registries/:id +func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL && r.ID != registry.ID { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + if payload.Name != "" { + registry.Name = payload.Name + } + + if payload.URL != "" { + registry.URL = payload.URL + } + + if payload.Authentication { + registry.Authentication = true + registry.Username = payload.Username + registry.Password = payload.Password + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_update_access.go b/api/http/handler/registries/registry_update_access.go new file mode 100644 index 000000000..a43ccb2f9 --- /dev/null +++ b/api/http/handler/registries/registry_update_access.go @@ -0,0 +1,63 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *registryUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/registries/:id/access +func (handler *Handler) registryUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + registry.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + registry.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go deleted file mode 100644 index 37cb2c971..000000000 --- a/api/http/handler/registry.go +++ /dev/null @@ -1,320 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// RegistryHandler represents an HTTP API handler for managing Docker registries. -type RegistryHandler struct { - *mux.Router - Logger *log.Logger - RegistryService portainer.RegistryService -} - -// NewRegistryHandler returns a new instance of RegistryHandler. -func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler { - h := &RegistryHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/registries", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost) - h.Handle("/registries", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut) - h.Handle("/registries/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete) - - return h -} - -type ( - postRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } - - postRegistriesResponse struct { - ID int `json:"Id"` - } - - putRegistryAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetRegistries handles GET requests on /registries -func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range filteredRegistries { - filteredRegistries[i].Password = "" - } - - encodeJSON(w, filteredRegistries, handler.Logger) -} - -// handlePostRegistries handles POST requests on /registries -func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) { - var req postRegistriesRequest - 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 - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - registry := &portainer.Registry{ - Name: req.Name, - URL: req.URL, - Authentication: req.Authentication, - Username: req.Username, - Password: req.Password, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.RegistryService.CreateRegistry(registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger) -} - -// handleGetRegistry handles GET requests on /registries/:id -func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registry.Password = "" - - encodeJSON(w, registry, handler.Logger) -} - -// handlePutRegistryAccess handles PUT requests on /registries/:id/access -func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistryAccessRequest - 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 - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - registry.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - registry.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutRegistry handles PUT requests on /registries/:id -func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistriesRequest - 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 - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL && r.ID != registry.ID { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - if req.Name != "" { - registry.Name = req.Name - } - - if req.URL != "" { - registry.URL = req.URL - } - - if req.Authentication { - registry.Authentication = true - registry.Username = req.Username - registry.Password = req.Password - } else { - registry.Authentication = false - registry.Username = "" - registry.Password = "" - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteRegistry handles DELETE requests on /registries/:id -func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go deleted file mode 100644 index fa939bb12..000000000 --- a/api/http/handler/resource_control.go +++ /dev/null @@ -1,266 +0,0 @@ -package handler - -import ( - "encoding/json" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// ResourceHandler represents an HTTP API handler for managing resource controls. -type ResourceHandler struct { - *mux.Router - Logger *log.Logger - ResourceControlService portainer.ResourceControlService -} - -// NewResourceHandler returns a new instance of ResourceHandler. -func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { - h := &ResourceHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/resource_controls", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete) - - return h -} - -type ( - postResourcesRequest struct { - ResourceID string `valid:"required"` - Type string `valid:"required"` - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - SubResourceIDs []string `valid:"-"` - } - - putResourcesRequest struct { - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - } -) - -// handlePostResources handles POST requests on /resources -func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { - var req postResourcesRequest - 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 - } - - var resourceControlType portainer.ResourceControlType - switch req.Type { - case "container": - resourceControlType = portainer.ContainerResourceControl - case "service": - resourceControlType = portainer.ServiceResourceControl - case "volume": - resourceControlType = portainer.VolumeResourceControl - case "network": - resourceControlType = portainer.NetworkResourceControl - case "secret": - resourceControlType = portainer.SecretResourceControl - case "stack": - resourceControlType = portainer.StackResourceControl - case "config": - resourceControlType = portainer.ConfigResourceControl - default: - httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) - return - } - - if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID) - if err != nil && err != portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if rc != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - - resourceControl := portainer.ResourceControl{ - ResourceID: req.ResourceID, - SubResourceIDs: req.SubResourceIDs, - Type: resourceControlType, - AdministratorsOnly: req.AdministratorsOnly, - UserAccesses: userAccesses, - TeamAccesses: teamAccesses, - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.CreateResourceControl(&resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - return -} - -// handlePutResources handles PUT requests on /resources/:id -func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putResourcesRequest - 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 - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControl.AdministratorsOnly = req.AdministratorsOnly - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - resourceControl.UserAccesses = userAccesses - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - resourceControl.TeamAccesses = teamAccesses - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteResources handles DELETE requests on /resources/:id -func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go new file mode 100644 index 000000000..4ad474f6b --- /dev/null +++ b/api/http/handler/resourcecontrols/handler.go @@ -0,0 +1,31 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle resource control operations. +type Handler struct { + *mux.Router + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage resource control operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/resource_controls", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go new file mode 100644 index 000000000..baaee3360 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -0,0 +1,116 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlCreatePayload struct { + ResourceID string + Type string + AdministratorsOnly bool + Users []int + Teams []int + SubResourceIDs []string +} + +func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.ResourceID) { + return portainer.Error("Invalid resource identifier") + } + + if govalidator.IsNull(payload.Type) { + return portainer.Error("Invalid type") + } + + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// POST request on /api/resource_controls +func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload resourceControlCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var resourceControlType portainer.ResourceControlType + switch payload.Type { + case "container": + resourceControlType = portainer.ContainerResourceControl + case "service": + resourceControlType = portainer.ServiceResourceControl + case "volume": + resourceControlType = portainer.VolumeResourceControl + case "network": + resourceControlType = portainer.NetworkResourceControl + case "secret": + resourceControlType = portainer.SecretResourceControl + case "stack": + resourceControlType = portainer.StackResourceControl + case "config": + resourceControlType = portainer.ConfigResourceControl + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", portainer.ErrInvalidResourceControlType} + } + + rc, err := handler.ResourceControlService.ResourceControlByResourceID(payload.ResourceID) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + if rc != nil { + return &httperror.HandlerError{http.StatusConflict, "A resource control is already associated to this resource", portainer.ErrResourceControlAlreadyExists} + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: payload.ResourceID, + SubResourceIDs: payload.SubResourceIDs, + Type: resourceControlType, + AdministratorsOnly: payload.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create a resource control for the specified resource", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the resource control inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go new file mode 100644 index 000000000..48fd8a972 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -0,0 +1,42 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/resource_controls/:id +func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go new file mode 100644 index 000000000..3f3b25799 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -0,0 +1,87 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlUpdatePayload struct { + AdministratorsOnly bool + Users []int + Teams []int +} + +func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// PUT request on /api/resource_controls/:id +func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + var payload resourceControlUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlAccess(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + } + + resourceControl.AdministratorsOnly = payload.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go deleted file mode 100644 index bef01db91..000000000 --- a/api/http/handler/settings.go +++ /dev/null @@ -1,181 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// SettingsHandler represents an HTTP API handler for managing Settings. -type SettingsHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService -} - -// NewSettingsHandler returns a new instance of OldSettingsHandler. -func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { - h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut) - h.Handle("/settings/public", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet) - h.Handle("/settings/authentication/checkLDAP", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut) - - return h -} - -type ( - publicSettingsResponse struct { - LogoURL string `json:"LogoURL"` - DisplayDonationHeader bool `json:"DisplayDonationHeader"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` - AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` - AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` - AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` - } - - putSettingsRequest struct { - TemplatesURL string `valid:"required"` - LogoURL string `valid:""` - BlackListedLabels []portainer.Pair `valid:""` - DisplayDonationHeader bool `valid:""` - DisplayExternalContributors bool `valid:""` - AuthenticationMethod int `valid:"required"` - LDAPSettings portainer.LDAPSettings `valid:""` - AllowBindMountsForRegularUsers bool `valid:""` - AllowPrivilegedModeForRegularUsers bool `valid:""` - } - - putSettingsLDAPCheckRequest struct { - LDAPSettings portainer.LDAPSettings `valid:""` - } -) - -// handleGetSettings handles GET requests on /settings -func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, settings, handler.Logger) - return -} - -// handleGetPublicSettings handles GET requests on /settings/public -func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - publicSettings := &publicSettingsResponse{ - LogoURL: settings.LogoURL, - DisplayDonationHeader: settings.DisplayDonationHeader, - DisplayExternalContributors: settings.DisplayExternalContributors, - AuthenticationMethod: settings.AuthenticationMethod, - AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, - } - - encodeJSON(w, publicSettings, handler.Logger) - return -} - -// handlePutSettings handles PUT requests on /settings -func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) { - var req putSettingsRequest - 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 - } - - settings := &portainer.Settings{ - TemplatesURL: req.TemplatesURL, - LogoURL: req.LogoURL, - BlackListedLabels: req.BlackListedLabels, - DisplayDonationHeader: req.DisplayDonationHeader, - DisplayExternalContributors: req.DisplayExternalContributors, - LDAPSettings: req.LDAPSettings, - AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: req.AllowPrivilegedModeForRegularUsers, - } - - if req.AuthenticationMethod == 1 { - settings.AuthenticationMethod = portainer.AuthenticationInternal - } else if req.AuthenticationMethod == 2 { - settings.AuthenticationMethod = portainer.AuthenticationLDAP - } else { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } else { - settings.LDAPSettings.TLSConfig.TLSCACertPath = "" - err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } - } - - err = handler.SettingsService.StoreSettings(settings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} - -// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check -func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) { - var req putSettingsLDAPCheckRequest - 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 - } - - if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } - - err = handler.LDAPService.TestConnectivity(&req.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go new file mode 100644 index 000000000..0850ec83e --- /dev/null +++ b/api/http/handler/settings/handler.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle settings operations. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService +} + +// NewHandler creates a handler to manage settings operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + h.Handle("/settings/public", + bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) + h.Handle("/settings/authentication/checkLDAP", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go new file mode 100644 index 000000000..48da08612 --- /dev/null +++ b/api/http/handler/settings/settings_inspect.go @@ -0,0 +1,18 @@ +package settings + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/settings +func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/settings/settings_ldap_check.go b/api/http/handler/settings/settings_ldap_check.go new file mode 100644 index 000000000..80d058e33 --- /dev/null +++ b/api/http/handler/settings/settings_ldap_check.go @@ -0,0 +1,40 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsLDAPCheckPayload struct { + LDAPSettings portainer.LDAPSettings +} + +func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /settings/ldap/check +func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsLDAPCheckPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } + + err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go new file mode 100644 index 000000000..ef76231f0 --- /dev/null +++ b/api/http/handler/settings/settings_public.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +type publicSettingsResponse struct { + LogoURL string `json:"LogoURL"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` +} + +// GET request on /api/settings/public +func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + publicSettings := &publicSettingsResponse{ + LogoURL: settings.LogoURL, + DisplayExternalContributors: settings.DisplayExternalContributors, + AuthenticationMethod: settings.AuthenticationMethod, + AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + } + + return response.JSON(w, publicSettings) +} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go new file mode 100644 index 000000000..1e854ec7f --- /dev/null +++ b/api/http/handler/settings/settings_update.go @@ -0,0 +1,85 @@ +package settings + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsUpdatePayload struct { + TemplatesURL string + LogoURL string + BlackListedLabels []portainer.Pair + DisplayExternalContributors bool + AuthenticationMethod int + LDAPSettings portainer.LDAPSettings + AllowBindMountsForRegularUsers bool + AllowPrivilegedModeForRegularUsers bool +} + +func (payload *settingsUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) { + return portainer.Error("Invalid templates URL. Must correspond to a valid URL format") + } + if payload.AuthenticationMethod == 0 { + return portainer.Error("Invalid authentication method") + } + if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 { + return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") + } + if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) { + return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") + } + return nil +} + +// PUT request on /api/settings +func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + settings := &portainer.Settings{ + TemplatesURL: payload.TemplatesURL, + LogoURL: payload.LogoURL, + BlackListedLabels: payload.BlackListedLabels, + DisplayExternalContributors: payload.DisplayExternalContributors, + LDAPSettings: payload.LDAPSettings, + AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers, + } + + settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod) + tlsError := handler.updateTLS(settings) + if tlsError != nil { + return tlsError + } + + err = handler.SettingsService.UpdateSettings(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} + } + + return response.JSON(w, settings) +} + +func (handler *Handler) updateTLS(settings *portainer.Settings) *httperror.HandlerError { + if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } else { + settings.LDAPSettings.TLSConfig.TLSCACertPath = "" + err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + return nil +} diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go deleted file mode 100644 index 5b9e53187..000000000 --- a/api/http/handler/stack.go +++ /dev/null @@ -1,794 +0,0 @@ -package handler - -import ( - "encoding/json" - "path" - "strconv" - "strings" - "sync" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - 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 { - stackCreationMutex *sync.Mutex - stackDeletionMutex *sync.Mutex - *mux.Router - Logger *log.Logger - FileService portainer.FileService - GitService portainer.GitService - StackService portainer.StackService - EndpointService portainer.EndpointService - ResourceControlService portainer.ResourceControlService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - StackManager portainer.StackManager -} - -type stackDeploymentConfig struct { - endpoint *portainer.Endpoint - stack *portainer.Stack - prune bool - dockerhub *portainer.DockerHub - registries []portainer.Registry -} - -// NewStackHandler returns a new instance of StackHandler. -func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { - h := &StackHandler{ - Router: mux.NewRouter(), - stackCreationMutex: &sync.Mutex{}, - stackDeletionMutex: &sync.Mutex{}, - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/{endpointId}/stacks", - bouncer.RestrictedAccess(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:""` - RepositoryURL string `valid:""` - RepositoryAuthentication bool `valid:""` - RepositoryUsername string `valid:""` - RepositoryPassword string `valid:""` - ComposeFilePathInRepository string `valid:""` - Env []portainer.Pair `valid:""` - } - postStacksResponse struct { - ID string `json:"Id"` - } - getStackFileResponse struct { - StackFileContent string `json:"StackFileContent"` - } - putStackRequest struct { - StackFileContent string `valid:"required"` - Env []portainer.Pair `valid:""` - Prune bool `valid:"-"` - } -) - -// 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: filesystem.ComposeFileDefaultName, - Env: req.Env, - } - - projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, 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 - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - 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 - swarmID := req.SwarmID - - if stackName == "" || swarmID == "" || req.RepositoryURL == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.RepositoryAuthentication && (req.RepositoryUsername == "" || req.RepositoryPassword == "") { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.ComposeFilePathInRepository == "" { - req.ComposeFilePathInRepository = filesystem.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.ComposeFilePathInRepository, - Env: req.Env, - } - - 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 - } - - if req.RepositoryAuthentication { - err = handler.GitService.ClonePrivateRepositoryWithBasicAuth(req.RepositoryURL, projectPath, req.RepositoryUsername, req.RepositoryPassword) - } else { - err = handler.GitService.ClonePublicRepository(req.RepositoryURL, 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 - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - 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 - } - - envParam := r.FormValue("Env") - var env []portainer.Pair - if err = json.Unmarshal([]byte(envParam), &env); err != nil { - 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: filesystem.ComposeFileDefaultName, - Env: env, - } - - projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stack.EntryPoint, 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 - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - 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 - } - stack.Env = req.Env - - _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, req.StackFileContent) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.StackService.UpdateStack(stack.ID, stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: req.Prune, - } - err = handler.deployStack(&config) - 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 - } - - handler.stackDeletionMutex.Lock() - err = handler.StackManager.Remove(stack, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - handler.stackDeletionMutex.Unlock() - - 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 - } -} - -func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error { - handler.stackCreationMutex.Lock() - - handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint) - - err := handler.StackManager.Deploy(config.stack, config.prune, config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - err = handler.StackManager.Logout(config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - handler.stackCreationMutex.Unlock() - return nil -} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go new file mode 100644 index 000000000..26e1c6217 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack.go @@ -0,0 +1,305 @@ +package stacks + +import ( + "net/http" + "strconv" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type composeStackFromFileContentPayload struct { + Name string + StackFileContent string +} + +func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromGitRepositoryPayload struct { + Name string + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromFileUploadPayload struct { + Name string + StackFileContent []byte +} + +func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + return nil +} + +func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &composeStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry +} + +func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &composeStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + } + + return config, nil +} + +// TODO: libcompose uses credentials store into a config.json file to pull images from +// private registries. Right now the only solution is to re-use the embedded Docker binary +// to login/logout, which will generate the required data in the config.json file and then +// clean it. Hence the use of the mutex. +// We should contribute to libcompose to support authentication without using the config.json file. +func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.ComposeStackManager.Up(config.stack, config.endpoint) + if err != nil { + return err + } + + return handler.SwarmStackManager.Logout(config.endpoint) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go new file mode 100644 index 000000000..6fee54221 --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -0,0 +1,337 @@ +package stacks + +import ( + "net/http" + "strconv" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type swarmStackFromFileContentPayload struct { + Name string + SwarmID string + StackFileContent string + Env []portainer.Pair +} + +func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromGitRepositoryPayload struct { + Name string + SwarmID string + Env []portainer.Pair + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + Env: payload.Env, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromFileUploadPayload struct { + Name string + SwarmID string + StackFileContent []byte + Env []portainer.Pair +} + +func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + swarmID, err := request.RetrieveMultiPartFormValue(r, "SwarmID", false) + if err != nil { + return portainer.Error("Invalid Swarm ID") + } + payload.SwarmID = swarmID + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + var env []portainer.Pair + err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) + if err != nil { + return portainer.Error("Invalid Env parameter") + } + payload.Env = env + return nil +} + +func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &swarmStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackID := handler.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry + prune bool +} + +func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &swarmStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: prune, + } + + return config, nil +} + +func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) + if err != nil { + return err + } + + err = handler.SwarmStackManager.Logout(config.endpoint) + if err != nil { + return err + } + + return nil +} diff --git a/api/http/handler/stacks/git.go b/api/http/handler/stacks/git.go new file mode 100644 index 000000000..1ac62a443 --- /dev/null +++ b/api/http/handler/stacks/git.go @@ -0,0 +1,16 @@ +package stacks + +type cloneRepositoryParameters struct { + url string + path string + authentication bool + username string + password string +} + +func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { + if parameters.authentication { + return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.path) +} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go new file mode 100644 index 000000000..ba231ebfe --- /dev/null +++ b/api/http/handler/stacks/handler.go @@ -0,0 +1,53 @@ +package stacks + +import ( + "net/http" + "sync" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle stack operations. +type Handler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex + requestBouncer *security.RequestBouncer + *mux.Router + FileService portainer.FileService + GitService portainer.GitService + StackService portainer.StackService + EndpointService portainer.EndpointService + ResourceControlService portainer.ResourceControlService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager +} + +// NewHandler creates a handler to manage stack operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + requestBouncer: bouncer, + } + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/file", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + h.Handle("/stacks/{id}/migrate", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go new file mode 100644 index 000000000..c0b9d9ce0 --- /dev/null +++ b/api/http/handler/stacks/stack_create.go @@ -0,0 +1,88 @@ +package stacks + +import ( + "log" + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" +) + +func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { + if !*doCleanUp { + return nil + } + + err := handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + log.Printf("http error: Unable to cleanup stack creation (err=%s)\n", err) + } + return nil +} + +// POST request on /api/stacks?type=&method=&endpointId= +func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackType, err := request.RetrieveNumericQueryParameter(r, "type", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: type", err} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + switch portainer.StackType(stackType) { + case portainer.DockerSwarmStack: + return handler.createSwarmStack(w, r, method, endpoint) + case portainer.DockerComposeStack: + return handler.createComposeStack(w, r, method, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + + switch method { + case "string": + return handler.createComposeStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createComposeStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createComposeStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createSwarmStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createSwarmStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go new file mode 100644 index 000000000..3844a4d7e --- /dev/null +++ b/api/http/handler/stacks/stack_delete.go @@ -0,0 +1,139 @@ +package stacks + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/stacks/:id?external=&endpointId= +// If the external query parameter is set to true, the id route variable is expected to be +// the name of an external stack as a string. +func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) + if externalStack { + return handler.deleteExternalStack(r, w, stackID) + } + + id, err := strconv.Atoi(stackID) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(id)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to set a valid endpoint identifier to be + // used in the context of this request. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + endpointIdentifier := stack.EndpointID + if endpointID != 0 { + endpointIdentifier = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.EndpointService.Endpoint(endpointIdentifier) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.DeleteStack(portainer.StackID(id)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} + } + + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string) *httperror.HandlerError { + stack, err := handler.StackService.StackByName(stackName) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} + } + if stack != nil { + return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", portainer.ErrStackNotExternal} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + + stack = &portainer.Stack{ + Name: stackName, + Type: portainer.DockerSwarmStack, + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete stack", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + if stack.Type == portainer.DockerSwarmStack { + return handler.SwarmStackManager.Remove(stack, endpoint) + } + return handler.ComposeStackManager.Down(stack, endpoint) +} diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go new file mode 100644 index 000000000..a0b644ba0 --- /dev/null +++ b/api/http/handler/stacks/stack_file.go @@ -0,0 +1,58 @@ +package stacks + +import ( + "net/http" + "path" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// GET request on /api/stacks/:id/file +func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent}) +} diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go new file mode 100644 index 000000000..28d5030ac --- /dev/null +++ b/api/http/handler/stacks/stack_inspect.go @@ -0,0 +1,48 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/stacks/:id +func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + return response.JSON(w, extendedStack) +} diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go new file mode 100644 index 000000000..fc4732e06 --- /dev/null +++ b/api/http/handler/stacks/stack_list.go @@ -0,0 +1,65 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackListOperationFilters struct { + SwarmID string `json:"SwarmID"` + EndpointID int `json:"EndpointID"` +} + +// GET request on /api/stacks?(filters=) +func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var filters stackListOperationFilters + err := request.RetrieveJSONQueryParameter(r, "filters", &filters, true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + stacks = filterStacks(stacks, &filters) + + resourceControls, err := handler.ResourceControlService.ResourceControls() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, + securityContext.UserID, securityContext.UserMemberships) + + return response.JSON(w, filteredStacks) +} + +func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack { + if filters.EndpointID == 0 && filters.SwarmID == "" { + return stacks + } + + filteredStacks := make([]portainer.Stack, 0, len(stacks)) + for _, stack := range stacks { + if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { + filteredStacks = append(filteredStacks, stack) + } + if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID { + filteredStacks = append(filteredStacks, stack) + } + } + + return filteredStacks +} diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go new file mode 100644 index 000000000..beb579a34 --- /dev/null +++ b/api/http/handler/stacks/stack_migrate.go @@ -0,0 +1,143 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackMigratePayload struct { + EndpointID int + SwarmID string +} + +func (payload *stackMigratePayload) Validate(r *http.Request) error { + if payload.EndpointID == 0 { + return portainer.Error("Invalid endpoint identifier. Must be a positive number") + } + return nil +} + +// POST request on /api/stacks/:id/migrate?endpointId= +func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + var payload stackMigratePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + targetEndpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(payload.EndpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + stack.EndpointID = portainer.EndpointID(payload.EndpointID) + if payload.SwarmID != "" { + stack.SwarmID = payload.SwarmID + } + + migrationError := handler.migrateStack(r, stack, targetEndpoint) + if migrationError != nil { + return migrationError + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.migrateSwarmStack(r, stack, next) + } + return handler.migrateComposeStack(r, stack, next) +} + +func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createComposeDeployConfig(r, stack, next) + if configErr != nil { + return configErr + } + + err := handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createSwarmDeployConfig(r, stack, next, true) + if configErr != nil { + return configErr + } + + err := handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go new file mode 100644 index 000000000..6ffef9112 --- /dev/null +++ b/api/http/handler/stacks/stack_update.go @@ -0,0 +1,160 @@ +package stacks + +import ( + "net/http" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type updateComposeStackPayload struct { + StackFileContent string +} + +func (payload *updateComposeStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +type updateSwarmStackPayload struct { + StackFileContent string + Env []portainer.Pair + Prune bool +} + +func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +// PUT request on /api/stacks/:id?endpointId= +func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + updateError := handler.updateAndDeployStack(r, stack, endpoint) + if updateError != nil { + return updateError + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.updateSwarmStack(r, stack, endpoint) + } + return handler.updateComposeStack(r, stack, endpoint) +} + +func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateComposeStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateSwarmStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack.Env = payload.Env + + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/api/http/handler/status.go b/api/http/handler/status.go deleted file mode 100644 index 6bae3c8a7..000000000 --- a/api/http/handler/status.go +++ /dev/null @@ -1,38 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// StatusHandler represents an HTTP API handler for managing Status. -type StatusHandler struct { - *mux.Router - Logger *log.Logger - Status *portainer.Status -} - -// NewStatusHandler returns a new instance of StatusHandler. -func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler { - h := &StatusHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - Status: status, - } - h.Handle("/status", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet) - - return h -} - -// handleGetStatus handles GET requests on /status -func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) { - encodeJSON(w, handler.Status, handler.Logger) - return -} diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go new file mode 100644 index 000000000..692c64130 --- /dev/null +++ b/api/http/handler/status/handler.go @@ -0,0 +1,28 @@ +package status + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle status operations. +type Handler struct { + *mux.Router + Status *portainer.Status +} + +// NewHandler creates a handler to manage status operations. +func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + Status: status, + } + h.Handle("/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/status/status_inspect.go b/api/http/handler/status/status_inspect.go new file mode 100644 index 000000000..93d379179 --- /dev/null +++ b/api/http/handler/status/status_inspect.go @@ -0,0 +1,13 @@ +package status + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/status +func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + return response.JSON(w, handler.Status) +} diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go new file mode 100644 index 000000000..a700f7c3e --- /dev/null +++ b/api/http/handler/tags/handler.go @@ -0,0 +1,31 @@ +package tags + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle tag operations. +type Handler struct { + *mux.Router + TagService portainer.TagService +} + +// NewHandler creates a handler to manage tag operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + h.Handle("/tags/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go new file mode 100644 index 000000000..f75c050b9 --- /dev/null +++ b/api/http/handler/tags/tag_create.go @@ -0,0 +1,53 @@ +package tags + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type tagCreatePayload struct { + Name string +} + +func (payload *tagCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid tag name") + } + return nil +} + +// POST request on /api/tags +func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload tagCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + for _, tag := range tags { + if tag.Name == payload.Name { + return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", portainer.ErrTagAlreadyExists} + } + } + + tag := &portainer.Tag{ + Name: payload.Name, + } + + err = handler.TagService.CreateTag(tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err} + } + + return response.JSON(w, tag) +} diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go new file mode 100644 index 000000000..b1b4fe867 --- /dev/null +++ b/api/http/handler/tags/tag_delete.go @@ -0,0 +1,25 @@ +package tags + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/tags/:name +func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid tag identifier route variable", err} + } + + err = handler.TagService.DeleteTag(portainer.TagID(id)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go new file mode 100644 index 000000000..b572add68 --- /dev/null +++ b/api/http/handler/tags/tag_list.go @@ -0,0 +1,18 @@ +package tags + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/tags +func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + return response.JSON(w, tags) +} diff --git a/api/http/handler/team.go b/api/http/handler/team.go deleted file mode 100644 index 1bf90e689..000000000 --- a/api/http/handler/team.go +++ /dev/null @@ -1,262 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamHandler represents an HTTP API handler for managing teams. -type TeamHandler struct { - *mux.Router - Logger *log.Logger - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamHandler returns a new instance of TeamHandler. -func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { - h := &TeamHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/teams", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) - h.Handle("/teams", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete) - h.Handle("/teams/{id}/memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - - return h -} - -type ( - postTeamsRequest struct { - Name string `valid:"required"` - } - - postTeamsResponse struct { - ID int `json:"Id"` - } - - putTeamRequest struct { - Name string `valid:"-"` - } -) - -// handlePostTeams handles POST requests on /teams -func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { - var req postTeamsRequest - 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 - } - - team, err := handler.TeamService.TeamByName(req.Name) - if err != nil && err != portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if team != nil { - httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - team = &portainer.Team{ - Name: req.Name, - } - - err = handler.TeamService.CreateTeam(team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) -} - -// handleGetTeams handles GET requests on /teams -func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - teams, err := handler.TeamService.Teams() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredTeams := security.FilterUserTeams(teams, securityContext) - - encodeJSON(w, filteredTeams, handler.Logger) -} - -// handleGetTeam handles GET requests on /teams/:id -func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - team, err := handler.TeamService.Team(teamID) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &team, handler.Logger) -} - -// handlePutTeam handles PUT requests on /teams/:id -func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamRequest - 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 - } - - team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - team.Name = req.Name - } - - err = handler.TeamService.UpdateTeam(team.ID, team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeam handles DELETE requests on /teams/:id -func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.TeamService.Team(portainer.TeamID(teamID)) - - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /teams/:id/memberships -func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go deleted file mode 100644 index c96f5c8ca..000000000 --- a/api/http/handler/team_membership.go +++ /dev/null @@ -1,242 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamMembershipHandler represents an HTTP API handler for managing teams. -type TeamMembershipHandler struct { - *mux.Router - Logger *log.Logger - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler. -func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler { - h := &TeamMembershipHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost) - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete) - - return h -} - -type ( - postTeamMembershipsRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } - - postTeamMembershipsResponse struct { - ID int `json:"Id"` - } - - putTeamMembershipRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } -) - -// handlePostTeamMemberships handles POST requests on /team_memberships -func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postTeamMembershipsRequest - 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 - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(memberships) > 0 { - for _, membership := range memberships { - if membership.UserID == userID && membership.TeamID == teamID { - httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - } - - membership := &portainer.TeamMembership{ - UserID: userID, - TeamID: teamID, - Role: role, - } - - err = handler.TeamMembershipService.CreateTeamMembership(membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) -} - -// handleGetTeamsMemberships handles GET requests on /team_memberships -func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMemberships() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} - -// handlePutTeamMembership handles PUT requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamMembershipRequest - 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 - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if securityContext.IsTeamLeader && membership.Role != role { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership.UserID = userID - membership.TeamID = teamID - membership.Role = role - - err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go new file mode 100644 index 000000000..c50773a85 --- /dev/null +++ b/api/http/handler/teammemberships/handler.go @@ -0,0 +1,35 @@ +package teammemberships + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle team membership operations. +type Handler struct { + *mux.Router + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team membership operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go new file mode 100644 index 000000000..49783d767 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -0,0 +1,74 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipCreatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// POST request on /api/team_memberships +func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamMembershipCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to manage team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(payload.UserID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == portainer.UserID(payload.UserID) && membership.TeamID == portainer.TeamID(payload.TeamID) { + return &httperror.HandlerError{http.StatusConflict, "Team membership already registered", portainer.ErrTeamMembershipAlreadyExists} + } + } + } + + membership := &portainer.TeamMembership{ + UserID: portainer.UserID(payload.UserID), + TeamID: portainer.TeamID(payload.TeamID), + Role: portainer.MembershipRole(payload.Role), + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go new file mode 100644 index 000000000..a1263745f --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -0,0 +1,42 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/team_memberships/:id +func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the membership", portainer.ErrResourceAccessDenied} + } + + err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go new file mode 100644 index 000000000..0f9267a10 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -0,0 +1,29 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/team_memberships +func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMemberships() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go new file mode 100644 index 000000000..6d08bc90a --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -0,0 +1,75 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipUpdatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// PUT request on /api/team_memberships/:id +func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + var payload teamMembershipUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", portainer.ErrResourceAccessDenied} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", portainer.ErrResourceAccessDenied} + } + + membership.UserID = portainer.UserID(payload.UserID) + membership.TeamID = portainer.TeamID(payload.TeamID) + membership.Role = portainer.MembershipRole(payload.Role) + + err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go new file mode 100644 index 000000000..2b8cd7c3b --- /dev/null +++ b/api/http/handler/teams/handler.go @@ -0,0 +1,39 @@ +package teams + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle team operations. +type Handler struct { + *mux.Router + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/teams", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) + h.Handle("/teams", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) + h.Handle("/teams/{id}/memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go new file mode 100644 index 000000000..d865e56c5 --- /dev/null +++ b/api/http/handler/teams/team_create.go @@ -0,0 +1,49 @@ +package teams + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamCreatePayload struct { + Name string +} + +func (payload *teamCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid team name") + } + return nil +} + +func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.TeamByName(payload.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + if team != nil { + return &httperror.HandlerError{http.StatusConflict, "A team with the same name already exists", portainer.ErrTeamAlreadyExists} + } + + team = &portainer.Team{ + Name: payload.Name, + } + + err = handler.TeamService.CreateTeam(team) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go new file mode 100644 index 000000000..623c29c4c --- /dev/null +++ b/api/http/handler/teams/team_delete.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/teams/:id +func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + _, err = handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the team from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go new file mode 100644 index 000000000..4030a391e --- /dev/null +++ b/api/http/handler/teams/team_inspect.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id +func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go new file mode 100644 index 000000000..7c4268e13 --- /dev/null +++ b/api/http/handler/teams/team_list.go @@ -0,0 +1,26 @@ +package teams + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams +func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teams, err := handler.TeamService.Teams() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredTeams := security.FilterUserTeams(teams, securityContext) + + return response.JSON(w, filteredTeams) +} diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go new file mode 100644 index 000000000..d09abe7d5 --- /dev/null +++ b/api/http/handler/teams/team_memberships.go @@ -0,0 +1,35 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id/memberships +func (handler *Handler) teamMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve associated team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go new file mode 100644 index 000000000..8c0961c31 --- /dev/null +++ b/api/http/handler/teams/team_update.go @@ -0,0 +1,50 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamUpdatePayload struct { + Name string +} + +func (payload *teamUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/teams/:id +func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + var payload teamUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + if payload.Name != "" { + team.Name = payload.Name + } + + err = handler.TeamService.UpdateTeam(team.ID, team) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to persist team changes inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go deleted file mode 100644 index 83527870b..000000000 --- a/api/http/handler/templates.go +++ /dev/null @@ -1,74 +0,0 @@ -package handler - -import ( - "io/ioutil" - "log" - "net/http" - "os" - - "github.com/gorilla/mux" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// TemplatesHandler represents an HTTP API handler for managing templates. -type TemplatesHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService -} - -const ( - containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" -) - -// NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler { - h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/templates", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet) - return h -} - -// handleGetTemplates handles GET requests on /templates?key= -func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { - key := r.FormValue("key") - if key == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - var templatesURL string - switch key { - case "containers": - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - templatesURL = settings.TemplatesURL - case "linuxserver.io": - templatesURL = containerTemplatesURLLinuxServerIo - default: - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - resp, err := http.Get(templatesURL) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(body) -} diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go new file mode 100644 index 000000000..f4d0c6fcf --- /dev/null +++ b/api/http/handler/templates/handler.go @@ -0,0 +1,30 @@ +package templates + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" +) + +// Handler represents an HTTP API handler for managing templates. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService +} + +// NewHandler returns a new instance of Handler. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go new file mode 100644 index 000000000..188951201 --- /dev/null +++ b/api/http/handler/templates/template_list.go @@ -0,0 +1,50 @@ +package templates + +import ( + "io/ioutil" + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/templates?key= +func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + key, err := request.RetrieveQueryParameter(r, "key", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err} + } + + templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key) + if templateErr != nil { + return templateErr + } + + resp, err := http.Get(templatesURL) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err} + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err} + } + + return response.Bytes(w, body, "application/json") +} + +func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) { + switch key { + case "containers": + settings, err := handler.SettingsService.Settings() + if err != nil { + return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + return settings.TemplatesURL, nil + case "linuxserver.io": + return containerTemplatesURLLinuxServerIo, nil + } + return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/upload.go b/api/http/handler/upload.go deleted file mode 100644 index 0343d1a2f..000000000 --- a/api/http/handler/upload.go +++ /dev/null @@ -1,69 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// UploadHandler represents an HTTP API handler for managing file uploads. -type UploadHandler struct { - *mux.Router - Logger *log.Logger - FileService portainer.FileService -} - -// NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { - h := &UploadHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost) - return h -} - -// 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"] - - folder := r.FormValue("folder") - if folder == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - file, _, err := r.FormFile("file") - defer file.Close() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var fileType portainer.TLSFileType - switch certificate { - case "ca": - fileType = portainer.TLSFileCA - case "cert": - fileType = portainer.TLSFileCert - case "key": - fileType = portainer.TLSFileKey - default: - httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.FileService.StoreTLSFile(folder, fileType, file) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go new file mode 100644 index 000000000..6ce36df77 --- /dev/null +++ b/api/http/handler/upload/handler.go @@ -0,0 +1,27 @@ +package upload + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle upload operations. +type Handler struct { + *mux.Router + FileService portainer.FileService +} + +// NewHandler creates a handler to manage upload operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go new file mode 100644 index 000000000..aebab6813 --- /dev/null +++ b/api/http/handler/upload/upload_tls.go @@ -0,0 +1,47 @@ +package upload + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// POST request on /api/upload/tls/{certificate:(?:ca|cert|key)}?folder= +func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + certificate, err := request.RetrieveRouteVariableValue(r, "certificate") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route variable", err} + } + + folder, err := request.RetrieveMultiPartFormValue(r, "folder", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: folder", err} + } + + file, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate file. Ensure that the certificate file is uploaded correctly", err} + } + + var fileType portainer.TLSFileType + switch certificate { + case "ca": + fileType = portainer.TLSFileCA + case "cert": + fileType = portainer.TLSFileCert + case "key": + fileType = portainer.TLSFileKey + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route value. Value must be one of: ca, cert or key", portainer.ErrUndefinedTLSFileType} + } + + _, err = handler.FileService.StoreTLSFileFromBytes(folder, fileType, file) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist certificate file on disk", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/user.go b/api/http/handler/user.go deleted file mode 100644 index 72952737d..000000000 --- a/api/http/handler/user.go +++ /dev/null @@ -1,468 +0,0 @@ -package handler - -import ( - "strconv" - "strings" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// UserHandler represents an HTTP API handler for managing users. -type UserHandler struct { - *mux.Router - Logger *log.Logger - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService - SettingsService portainer.SettingsService -} - -// NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { - h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) - h.Handle("/users/{id}/memberships", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - h.Handle("/users/{id}/passwd", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost) - h.Handle("/users/admin/check", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet) - h.Handle("/users/admin/init", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost) - - return h -} - -type ( - postUsersRequest struct { - Username string `valid:"required"` - Password string `valid:""` - Role int `valid:"required"` - } - - postUsersResponse struct { - ID int `json:"Id"` - } - - postUserPasswdRequest struct { - Password string `valid:"required"` - } - - postUserPasswdResponse struct { - Valid bool `json:"valid"` - } - - putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` - } - - postAdminInitRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } -) - -// handlePostUsers handles POST requests on /users -func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - var req postUsersRequest - 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 - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if securityContext.IsTeamLeader && req.Role == 1 { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if strings.ContainsAny(req.Username, " ") { - httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.UserByUsername(req.Username) - if err != nil && err != portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - - user = &portainer.User{ - Username: req.Username, - Role: role, - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationInternal { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) -} - -// handleGetUsers handles GET requests on /users -func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - users, err := handler.UserService.Users() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredUsers := security.FilterUsers(users, securityContext) - - for i := range filteredUsers { - filteredUsers[i].Password = "" - } - - encodeJSON(w, filteredUsers, handler.Logger) -} - -// handlePostUserPasswd handles POST requests on /users/:id/passwd -func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req postUserPasswdRequest - 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 - } - - var password = req.Password - - u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - valid := true - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - valid = false - } - - encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) -} - -// handleGetUser handles GET requests on /users/:id -func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) -} - -// handlePutUser handles PUT requests on /users/:id -func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - var req putUserRequest - 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 - } - - if req.Password == "" && req.Role == 0 { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Password != "" { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - if req.Role != 0 { - if tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - if req.Role == 1 { - user.Role = portainer.AdministratorRole - } else { - user.Role = portainer.StandardUserRole - } - } - - err = handler.UserService.UpdateUser(user.ID, user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetAdminCheck handles GET requests on /users/admin/check -func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) - return - } -} - -// handlePostAdminInit handles POST requests on /users/admin/init -func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - var req postAdminInitRequest - 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 - } - - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - user := &portainer.User{ - Username: req.Username, - Role: portainer.AdministratorRole, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger) - return - } -} - -// handleDeleteUser handles DELETE requests on /users/:id -func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - if userID == 1 { - httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveAdmin, http.StatusForbidden, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.ID == portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger) - return - } - - _, err = handler.UserService.User(portainer.UserID(userID)) - - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.UserService.DeleteUser(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /users/:id/memberships -func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go new file mode 100644 index 000000000..4d7ba233a --- /dev/null +++ b/api/http/handler/users/admin_check.go @@ -0,0 +1,23 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/admin/check +func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) == 0 { + return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrObjectNotFound} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go new file mode 100644 index 000000000..2ad394803 --- /dev/null +++ b/api/http/handler/users/admin_init.go @@ -0,0 +1,61 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type adminInitPayload struct { + Username string + Password string +} + +func (payload *adminInitPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +// POST request on /api/users/admin/init +func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload adminInitPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) != 0 { + return &httperror.HandlerError{http.StatusConflict, "Unable to retrieve users from the database", portainer.ErrAdminAlreadyInitialized} + } + + user := &portainer.User{ + Username: payload.Username, + Role: portainer.AdministratorRole, + } + + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go new file mode 100644 index 000000000..f6e4df727 --- /dev/null +++ b/api/http/handler/users/handler.go @@ -0,0 +1,53 @@ +package users + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(user *portainer.User) { + user.Password = "" +} + +// Handler is the HTTP handler used to handle user operations. +type Handler struct { + *mux.Router + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage user operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + h.Handle("/users/{id}/memberships", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) + h.Handle("/users/{id}/passwd", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userPassword))).Methods(http.MethodPost) + h.Handle("/users/admin/check", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet) + h.Handle("/users/admin/init", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go new file mode 100644 index 000000000..9fb4cddda --- /dev/null +++ b/api/http/handler/users/user_create.go @@ -0,0 +1,84 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userCreatePayload struct { + Username string + Password string + Role int +} + +func (payload *userCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// POST request on /api/users +func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload userCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", portainer.ErrResourceAccessDenied} + } + + if securityContext.IsTeamLeader && payload.Role == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.UserByUsername(payload.Username) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + if user != nil { + return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", portainer.ErrUserAlreadyExists} + } + + user = &portainer.User{ + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationInternal { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go new file mode 100644 index 000000000..c183df7a7 --- /dev/null +++ b/api/http/handler/users/user_delete.go @@ -0,0 +1,47 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/users/:id +func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.ID == portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf} + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go new file mode 100644 index 000000000..9583c833c --- /dev/null +++ b/api/http/handler/users/user_inspect.go @@ -0,0 +1,28 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/:id +func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go new file mode 100644 index 000000000..760c4ec54 --- /dev/null +++ b/api/http/handler/users/user_list.go @@ -0,0 +1,29 @@ +package users + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users +func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.Users() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredUsers := security.FilterUsers(users, securityContext) + + for _, user := range filteredUsers { + hideFields(&user) + } + return response.JSON(w, filteredUsers) +} diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go new file mode 100644 index 000000000..dfbb355ab --- /dev/null +++ b/api/http/handler/users/user_memberships.go @@ -0,0 +1,35 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users/:id/memberships +func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user memberships", portainer.ErrUnauthorized} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/users/user_password.go b/api/http/handler/users/user_password.go new file mode 100644 index 000000000..d50f88879 --- /dev/null +++ b/api/http/handler/users/user_password.go @@ -0,0 +1,57 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type userPasswordPayload struct { + Password string +} + +func (payload *userPasswordPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +type userPasswordResponse struct { + Valid bool `json:"valid"` +} + +// POST request on /api/users/:id/passwd +func (handler *Handler) userPassword(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + var payload userPasswordPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var password = payload.Password + + u, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + return response.JSON(w, &userPasswordResponse{Valid: valid}) +} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go new file mode 100644 index 000000000..a8b6c8b1a --- /dev/null +++ b/api/http/handler/users/user_update.go @@ -0,0 +1,75 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userUpdatePayload struct { + Password string + Role int +} + +func (payload *userUpdatePayload) Validate(r *http.Request) error { + if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// PUT request on /api/users/:id +func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", portainer.ErrUnauthorized} + } + + var payload userUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user to administrator role", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + if payload.Password != "" { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + if payload.Role != 0 { + user.Role = portainer.UserRole(payload.Role) + } + + err = handler.UserService.UpdateUser(user.ID, user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go new file mode 100644 index 000000000..20364f52b --- /dev/null +++ b/api/http/handler/websocket/handler.go @@ -0,0 +1,30 @@ +package websocket + +import ( + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle websocket operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + requestBouncer *security.RequestBouncer + connectionUpgrader websocket.Upgrader +} + +// NewHandler creates a handler to manage websocket operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + connectionUpgrader: websocket.Upgrader{}, + requestBouncer: bouncer, + } + h.PathPrefix("/websocket/exec").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) + return h +} diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket/websocket_exec.go similarity index 73% rename from api/http/handler/websocket.go rename to api/http/handler/websocket/websocket_exec.go index 629e60390..6e46084a6 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -1,4 +1,4 @@ -package handler +package websocket import ( "bufio" @@ -6,94 +6,78 @@ import ( "crypto/tls" "encoding/json" "fmt" - "log" "net" "net/http" "net/http/httputil" "net/url" - "os" - "strconv" "time" - "github.com/gorilla/mux" + "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" "github.com/koding/websocketproxy" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" ) -type ( - // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. - WebSocketHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - SignatureService portainer.DigitalSignatureService - connectionUpgrader websocket.Upgrader - } - - webSocketExecRequestParams struct { - execID string - nodeName string - endpoint *portainer.Endpoint - } - - execStartOperationPayload struct { - Tty bool - Detach bool - } -) - -// NewWebSocketHandler returns a new instance of WebSocketHandler. -func NewWebSocketHandler() *WebSocketHandler { - h := &WebSocketHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - connectionUpgrader: websocket.Upgrader{}, - } - h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet) - return h +type webSocketExecRequestParams struct { + execID string + nodeName string + endpoint *portainer.Endpoint } -// handleWebsocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +type execStartOperationPayload struct { + Tty bool + Detach bool +} + +// websocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName=&token= // If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. // If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // an ExecStart operation HTTP request will be created and hijacked. -func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) { - paramExecID := r.FormValue("id") - paramEndpointID := r.FormValue("endpointId") - if paramExecID == "" || paramEndpointID == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return +// Authentication and access is controled via the mandatory token query parameter. +func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + execID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id", err} + } + if !govalidator.IsHexadecimal(execID) { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id (must be hexadecimal identifier)", err} } - endpointID, err := strconv.Atoi(paramEndpointID) + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } params := &webSocketExecRequestParams{ endpoint: endpoint, - execID: paramExecID, + execID: execID, nodeName: r.FormValue("nodeName"), } err = handler.handleRequest(w, r, params) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket exec operation", err} } + + return nil } -func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { r.Header.Del("Origin") if params.nodeName != "" { @@ -109,7 +93,7 @@ func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Re return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) } -func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { agentURL, err := url.Parse(params.endpoint.URL) if err != nil { return err diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index 408ff8c7d..331b9d0da 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -10,7 +10,7 @@ type ( } ) -// applyResourceAccessControl returns an optionally decorated object as the first return value and the +// applyResourceAccessControlFromLabel 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. diff --git a/api/http/proxy/azure_transport.go b/api/http/proxy/azure_transport.go new file mode 100644 index 000000000..dccf451b5 --- /dev/null +++ b/api/http/proxy/azure_transport.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + // AzureTransport represents a transport used when executing HTTP requests + // against the Azure API. + AzureTransport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + } +) + +// NewAzureTransport returns a pointer to an AzureTransport instance. +func NewAzureTransport(credentials *portainer.AzureCredentials) *AzureTransport { + return &AzureTransport{ + credentials: credentials, + client: client.NewHTTPClient(), + } +} + +func (transport *AzureTransport) authenticate() error { + token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) + if err != nil { + return err + } + + expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64) + if err != nil { + return err + } + + transport.token = &azureAPIToken{ + value: token.AccessToken, + expirationTime: time.Unix(expiresOn, 0), + } + + return nil +} + +func (transport *AzureTransport) retrieveAuthenticationToken() error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + + if transport.token == nil { + return transport.authenticate() + } + + timeLimit := time.Now().Add(-5 * time.Minute) + if timeLimit.After(transport.token.expirationTime) { + return transport.authenticate() + } + + return nil +} + +// RoundTrip is the implementation of the Transport interface. +func (transport *AzureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + return http.DefaultTransport.RoundTrip(request) +} diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index b05149940..9da66e21f 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -8,10 +8,11 @@ import ( const ( // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier - ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") - containerIdentifier = "Id" - containerLabelForServiceIdentifier = "com.docker.swarm.service.id" - containerLabelForStackIdentifier = "com.docker.stack.namespace" + ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") + containerIdentifier = "Id" + containerLabelForServiceIdentifier = "com.docker.swarm.service.id" + containerLabelForSwarmStackIdentifier = "com.docker.stack.namespace" + containerLabelForComposeStackIdentifier = "com.docker.compose.project" ) // containerListOperation extracts the response as a JSON object, loop through the containers array @@ -71,7 +72,12 @@ func containerInspectOperation(response *http.Response, executor *operationExecu return rewriteAccessDeniedResponse(response) } - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext) + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForSwarmStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForComposeStackIdentifier, executor.operationContext) if !access { return rewriteAccessDeniedResponse(response) } @@ -117,7 +123,8 @@ func decorateContainerList(containerData []interface{}, resourceControls []porta containerLabels := extractContainerLabelsFromContainerListObject(containerObject) containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, resourceControls) decoratedContainerData = append(decoratedContainerData, containerObject) } @@ -143,11 +150,14 @@ func filterContainerList(containerData []interface{}, context *restrictedOperati containerObject, access := applyResourceAccessControl(containerObject, containerID, context) if access { containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, context) if access { - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) if access { - filteredContainerData = append(filteredContainerData, containerObject) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, context) + if access { + filteredContainerData = append(filteredContainerData, containerObject) + } } } } diff --git a/api/http/proxy/transport.go b/api/http/proxy/docker_transport.go similarity index 100% rename from api/http/proxy/transport.go rename to api/http/proxy/docker_transport.go diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 8f952f2dc..f3c43510f 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -10,6 +10,9 @@ import ( "github.com/portainer/portainer/crypto" ) +// AzureAPIBaseURL is the URL where Azure API requests will be proxied. +const AzureAPIBaseURL = "https://management.azure.com" + // proxyFactory is a factory to create reverse proxies to Docker endpoints type proxyFactory struct { ResourceControlService portainer.ResourceControlService @@ -20,11 +23,23 @@ type proxyFactory struct { SignatureService portainer.DigitalSignatureService } -func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" return newSingleHostReverseProxyWithHostHeader(u) } +func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { + url, err := url.Parse(AzureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = NewAzureTransport(credentials) + + return proxy, nil +} + func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) { u.Scheme = "https" diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 2a9018102..88acdf515 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -44,33 +44,39 @@ func NewManager(parameters *ManagerParams) *Manager { } } -// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler +func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) { + if endpointURL.Scheme == "tcp" { + if tlsConfig.TLS || tlsConfig.TLSSkipVerify { + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false) + } + return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil + } + // Assume unix:// scheme + return manager.proxyFactory.newDockerSocketProxy(endpointURL.Path), nil +} +func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { endpointURL, err := url.Parse(endpoint.URL) if err != nil { return nil, err } - enableSignature := false - if endpoint.Type == portainer.AgentOnDockerEnvironment { - enableSignature = true + switch endpoint.Type { + case portainer.AgentOnDockerEnvironment: + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true) + case portainer.AzureEnvironment: + return newAzureProxy(&endpoint.AzureCredentials) + default: + return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig) } +} - if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature) - if err != nil { - return nil, err - } - } else { - proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature) - } - } else { - // Assume unix:// scheme - proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path) +// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.createProxy(endpoint) + if err != nil { + return nil, err } manager.proxies.Set(string(endpoint.ID), proxy) @@ -93,13 +99,12 @@ func (manager *Manager) DeleteProxy(key string) { // CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies. func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { - extensionURL, err := url.Parse(extensionAPIURL) if err != nil { return nil, err } - proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL) + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) manager.extensionProxies.Set(key, proxy) return proxy, nil } diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/reverse_proxy.go index 4862de9a9..47e71b63e 100644 --- a/api/http/proxy/reverse_proxy.go +++ b/api/http/proxy/reverse_proxy.go @@ -7,7 +7,7 @@ import ( "strings" ) -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // HTTP header, which NewSingleHostReverseProxy deliberately preserves. func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go index 5a9158492..58ccdec27 100644 --- a/api/http/proxy/socket.go +++ b/api/http/proxy/socket.go @@ -3,6 +3,7 @@ package proxy // unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket import ( "io" + "log" "net/http" httperror "github.com/portainer/portainer/http/error" @@ -24,7 +25,7 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if res != nil && res.StatusCode != 0 { code = res.StatusCode } - httperror.WriteErrorResponse(w, err, code, nil) + httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) return } defer res.Body.Close() @@ -38,6 +39,6 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(res.StatusCode) if _, err := io.Copy(w, res.Body); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + log.Printf("proxy error: %s\n", err) } } diff --git a/api/http/request/request.go b/api/http/request/request.go new file mode 100644 index 000000000..16ba4590b --- /dev/null +++ b/api/http/request/request.go @@ -0,0 +1,160 @@ +package request + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" +) + +const ( + // ErrInvalidQueryParameter defines an error raised when a mandatory query parameter has an invalid value. + ErrInvalidQueryParameter = portainer.Error("Invalid query parameter") + // errInvalidRequestURL defines an error raised when the data sent in the query or the URL is invalid + errInvalidRequestURL = portainer.Error("Invalid request URL") + // errMissingQueryParameter defines an error raised when a mandatory query parameter is missing. + errMissingQueryParameter = portainer.Error("Missing query parameter") + // errMissingFormDataValue defines an error raised when a mandatory form data value is missing. + errMissingFormDataValue = portainer.Error("Missing form data value") +) + +// PayloadValidation is an interface used to validate the payload of a request. +type PayloadValidation interface { + Validate(request *http.Request) error +} + +// DecodeAndValidateJSONPayload decodes the body of the request into an object +// implementing the PayloadValidation interface. +// It also triggers a validation of object content. +func DecodeAndValidateJSONPayload(request *http.Request, v PayloadValidation) error { + if err := json.NewDecoder(request.Body).Decode(v); err != nil { + return err + } + return v.Validate(request) +} + +// RetrieveMultiPartFormFile returns the content of an uploaded file (form data) as bytes. +func RetrieveMultiPartFormFile(request *http.Request, requestParameter string) ([]byte, error) { + file, _, err := request.FormFile(requestParameter) + if err != nil { + return nil, err + } + defer file.Close() + + fileContent, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + return fileContent, nil +} + +// RetrieveMultiPartFormJSONValue decodes the value of some form data as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormJSONValue(request *http.Request, name string, target interface{}, optional bool) error { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return err + } + if value == "" { + return nil + } + return json.Unmarshal([]byte(value), target) +} + +// RetrieveMultiPartFormValue returns the value of some form data as a string. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormValue(request *http.Request, name string, optional bool) (string, error) { + value := request.FormValue(name) + if value == "" && !optional { + return "", errMissingFormDataValue + } + return value, nil +} + +// RetrieveNumericMultiPartFormValue returns the value of some form data as an integer. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveNumericMultiPartFormValue(request *http.Request, name string, optional bool) (int, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(value) +} + +// RetrieveBooleanMultiPartFormValue returns the value of some form data as a boolean. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveBooleanMultiPartFormValue(request *http.Request, name string, optional bool) (bool, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return false, err + } + return value == "true", nil +} + +// RetrieveRouteVariableValue returns the value of a route variable as a string. +func RetrieveRouteVariableValue(request *http.Request, name string) (string, error) { + routeVariables := mux.Vars(request) + if routeVariables == nil { + return "", errInvalidRequestURL + } + routeVar := routeVariables[name] + if routeVar == "" { + return "", errInvalidRequestURL + } + return routeVar, nil +} + +// RetrieveNumericRouteVariableValue returns the value of a route variable as an integer. +func RetrieveNumericRouteVariableValue(request *http.Request, name string) (int, error) { + routeVar, err := RetrieveRouteVariableValue(request, name) + if err != nil { + return 0, err + } + return strconv.Atoi(routeVar) +} + +// RetrieveQueryParameter returns the value of a query parameter as a string. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveQueryParameter(request *http.Request, name string, optional bool) (string, error) { + queryParameter := request.FormValue(name) + if queryParameter == "" && !optional { + return "", errMissingQueryParameter + } + return queryParameter, nil +} + +// RetrieveNumericQueryParameter returns the value of a query parameter as an integer. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveNumericQueryParameter(request *http.Request, name string, optional bool) (int, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(queryParameter) +} + +// RetrieveBooleanQueryParameter returns the value of a query parameter as a boolean. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveBooleanQueryParameter(request *http.Request, name string, optional bool) (bool, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return false, err + } + return queryParameter == "true", nil +} + +// RetrieveJSONQueryParameter decodes the value of a query paramater as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveJSONQueryParameter(request *http.Request, name string, target interface{}, optional bool) error { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return err + } + if queryParameter == "" { + return nil + } + return json.Unmarshal([]byte(queryParameter), target) +} diff --git a/api/http/response/response.go b/api/http/response/response.go new file mode 100644 index 000000000..1334d4d7e --- /dev/null +++ b/api/http/response/response.go @@ -0,0 +1,32 @@ +package response + +import ( + "encoding/json" + "net/http" + + httperror "github.com/portainer/portainer/http/error" +) + +// JSON encodes data to rw in JSON format. Returns a pointer to a +// HandlerError if encoding fails. +func JSON(rw http.ResponseWriter, data interface{}) *httperror.HandlerError { + rw.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(rw).Encode(data) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to write JSON response", err} + } + return nil +} + +// Empty merely sets the response code to NoContent (204). +func Empty(rw http.ResponseWriter) *httperror.HandlerError { + rw.WriteHeader(http.StatusNoContent) + return nil +} + +// Bytes write data into rw. It also allows to set the Content-Type header. +func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError { + rw.Header().Set("Content-Type", contentType) + rw.Write(data) + return nil +} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index a2efe216a..7fa7a6f31 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -40,6 +40,43 @@ func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceContro return false } +// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. +func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + authorizedTeamAccess := false + for _, access := range resourceControl.TeamAccesses { + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID { + authorizedTeamAccess = true + break + } + } + } + if !authorizedTeamAccess { + return false + } + + authorizedUserAccess := false + for _, access := range resourceControl.UserAccesses { + if context.UserID == access.UserID { + authorizedUserAccess = true + break + } + } + if !authorizedUserAccess { + return false + } + + return true +} + // AuthorizedResourceControlUpdate ensure that the user can update a resource control object. // It reuses the creation restrictions and adds extra checks. // A non-administrator user cannot update a resource control where: @@ -56,7 +93,9 @@ func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, // AuthorizedResourceControlCreation ensure that the user can create a resource control object. // A non-administrator user cannot create a resource control where: // * the AdministratorsOnly flag is set +// * he wants to create a resource control without any user/team accesses // * he wants to add more than one user in the user accesses +// * he wants tp add a user in the user accesses that is not corresponding to its id // * he wants to add a team he is not a member of func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin { @@ -69,6 +108,11 @@ func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceContro userAccessesCount := len(resourceControl.UserAccesses) teamAccessesCount := len(resourceControl.TeamAccesses) + + if userAccessesCount == 0 && teamAccessesCount == 0 { + return false + } + if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) { return false } @@ -122,10 +166,10 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques return false } -// AuthorizedEndpointAccess ensure that the user can access the specified endpoint. +// authorizedEndpointAccess ensure that the user can access the specified endpoint. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the endpoint and the associated group. -func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { +func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) if !groupAccess { return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams) diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 76b47aaea..798d37f7b 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -14,9 +14,19 @@ type ( jwtService portainer.JWTService userService portainer.UserService teamMembershipService portainer.TeamMembershipService + endpointGroupService portainer.EndpointGroupService authDisabled bool } + // RequestBouncerParams represents the required parameters to create a new RequestBouncer instance. + RequestBouncerParams struct { + JWTService portainer.JWTService + UserService portainer.UserService + TeamMembershipService portainer.TeamMembershipService + EndpointGroupService portainer.EndpointGroupService + AuthDisabled bool + } + // RestrictedRequestContext is a data structure containing information // used in RestrictedAccess RestrictedRequestContext struct { @@ -28,12 +38,13 @@ type ( ) // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { +func NewRequestBouncer(parameters *RequestBouncerParams) *RequestBouncer { return &RequestBouncer{ - jwtService: jwtService, - userService: userService, - teamMembershipService: teamMembershipService, - authDisabled: authDisabled, + jwtService: parameters.JWTService, + userService: parameters.UserService, + teamMembershipService: parameters.TeamMembershipService, + endpointGroupService: parameters.EndpointGroupService, + authDisabled: parameters.AuthDisabled, } } @@ -70,6 +81,36 @@ func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler return h } +// EndpointAccess retrieves the JWT token from the request context and verifies +// that the user can access the specified endpoint. +// An error is returned when access is denied. +func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portainer.Endpoint) error { + tokenData, err := RetrieveTokenData(r) + if err != nil { + return err + } + + if tokenData.Role == portainer.AdministratorRole { + return nil + } + + memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return err + } + + group, err := bouncer.endpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !authorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -85,13 +126,13 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) return } @@ -105,7 +146,7 @@ func mwCheckAdministratorRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil || tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } @@ -120,6 +161,10 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han if !bouncer.authDisabled { var token string + // Optionally, token might be set via the "token" query parameter. + // For example, in websocket requests + token = r.URL.Query().Get("token") + // Get token from the Authorization header tokens, ok := r.Header["Authorization"] if ok && len(tokens) >= 1 { @@ -128,23 +173,23 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han } if token == "" { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } var err error tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) return } _, err = bouncer.userService.User(tokenData.ID) - if err != nil && err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + if err != nil && err == portainer.ErrObjectNotFound { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve users from the database", err) return } } else { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 5c1b0774f..0e00ab568 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -62,8 +62,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po // FilterRegistries filters registries based on user role and team memberships. // Non administrator users only have access to authorized registries. -func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { - +func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry { filteredRegistries := registries if !context.IsAdmin { filteredRegistries = make([]portainer.Registry, 0) @@ -75,12 +74,12 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques } } - return filteredRegistries, nil + return filteredRegistries } // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). -func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { filteredEndpoints := endpoints if !context.IsAdmin { @@ -89,18 +88,18 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint for _, endpoint := range endpoints { endpointGroup := getAssociatedGroup(&endpoint, groups) - if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { + if authorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } } - return filteredEndpoints, nil + return filteredEndpoints } // FilterEndpointGroups filters endpoint groups based on user role and team memberships. // Non administrator users only have access to authorized endpoint groups. -func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) { +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { filteredEndpointGroups := endpointGroups if !context.IsAdmin { @@ -113,7 +112,7 @@ func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *Res } } - return filteredEndpointGroups, nil + return filteredEndpointGroups } func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go index 0eb89e0c1..27ab2523a 100644 --- a/api/http/security/rate_limiter.go +++ b/api/http/security/rate_limiter.go @@ -30,7 +30,7 @@ func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := StripAddrPort(r.RemoteAddr) if banned := limiter.Inc(ip); banned == true { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } next.ServeHTTP(w, r) diff --git a/api/http/server.go b/api/http/server.go index 5e85f8efa..7569760dd 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -5,7 +5,24 @@ import ( "github.com/portainer/portainer" "github.com/portainer/portainer/http/handler" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -20,23 +37,25 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + DockerHubService portainer.DockerHubService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + GitService portainer.GitService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService SettingsService portainer.SettingsService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - FileService portainer.FileService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService StackService portainer.StackService - StackManager portainer.StackManager - LDAPService portainer.LDAPService - GitService portainer.GitService - SignatureService portainer.DigitalSignatureService + SwarmStackManager portainer.SwarmStackManager + TagService portainer.TagService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + UserService portainer.UserService Handler *handler.Handler SSL bool SSLCert string @@ -45,7 +64,14 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { - requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) + requestBouncerParameters := &security.RequestBouncerParams{ + JWTService: server.JWTService, + UserService: server.UserService, + TeamMembershipService: server.TeamMembershipService, + EndpointGroupService: server.EndpointGroupService, + AuthDisabled: server.AuthDisabled, + } + requestBouncer := security.NewRequestBouncer(requestBouncerParameters) proxyManagerParameters := &proxy.ManagerParams{ ResourceControlService: server.ResourceControlService, TeamMembershipService: server.TeamMembershipService, @@ -57,94 +83,102 @@ func (server *Server) Start() error { proxyManager := proxy.NewManager(proxyManagerParameters) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) - var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService authHandler.SettingsService = server.SettingsService - var userHandler = handler.NewUserHandler(requestBouncer) + + var dockerHubHandler = dockerhub.NewHandler(requestBouncer) + dockerHubHandler.DockerHubService = server.DockerHubService + + var endpointHandler = endpoints.NewHandler(requestBouncer, server.EndpointManagement) + endpointHandler.EndpointService = server.EndpointService + endpointHandler.EndpointGroupService = server.EndpointGroupService + endpointHandler.FileService = server.FileService + endpointHandler.ProxyManager = proxyManager + + var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointService = server.EndpointService + + var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) + endpointProxyHandler.EndpointService = server.EndpointService + endpointProxyHandler.ProxyManager = proxyManager + + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + + var registryHandler = registries.NewHandler(requestBouncer) + registryHandler.RegistryService = server.RegistryService + + var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) + resourceControlHandler.ResourceControlService = server.ResourceControlService + + var settingsHandler = settings.NewHandler(requestBouncer) + settingsHandler.SettingsService = server.SettingsService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.FileService = server.FileService + + var stackHandler = stacks.NewHandler(requestBouncer) + stackHandler.FileService = server.FileService + stackHandler.StackService = server.StackService + stackHandler.EndpointService = server.EndpointService + stackHandler.ResourceControlService = server.ResourceControlService + stackHandler.SwarmStackManager = server.SwarmStackManager + stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.GitService = server.GitService + stackHandler.RegistryService = server.RegistryService + stackHandler.DockerHubService = server.DockerHubService + + var tagHandler = tags.NewHandler(requestBouncer) + tagHandler.TagService = server.TagService + + var teamHandler = teams.NewHandler(requestBouncer) + teamHandler.TeamService = server.TeamService + teamHandler.TeamMembershipService = server.TeamMembershipService + + var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) + teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + var statusHandler = status.NewHandler(requestBouncer, server.Status) + + var templatesHandler = templates.NewHandler(requestBouncer) + templatesHandler.SettingsService = server.SettingsService + + var uploadHandler = upload.NewHandler(requestBouncer) + uploadHandler.FileService = server.FileService + + var userHandler = users.NewHandler(requestBouncer) userHandler.UserService = server.UserService userHandler.TeamService = server.TeamService userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService - var teamHandler = handler.NewTeamHandler(requestBouncer) - teamHandler.TeamService = server.TeamService - teamHandler.TeamMembershipService = server.TeamMembershipService - var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) - teamMembershipHandler.TeamMembershipService = server.TeamMembershipService - var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status) - var settingsHandler = handler.NewSettingsHandler(requestBouncer) - settingsHandler.SettingsService = server.SettingsService - settingsHandler.LDAPService = server.LDAPService - settingsHandler.FileService = server.FileService - var templatesHandler = handler.NewTemplatesHandler(requestBouncer) - templatesHandler.SettingsService = server.SettingsService - var dockerHandler = handler.NewDockerHandler(requestBouncer) - dockerHandler.EndpointService = server.EndpointService - dockerHandler.EndpointGroupService = server.EndpointGroupService - dockerHandler.TeamMembershipService = server.TeamMembershipService - dockerHandler.ProxyManager = proxyManager - var websocketHandler = handler.NewWebSocketHandler() + + var websocketHandler = websocket.NewHandler(requestBouncer) websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService - var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) - endpointHandler.EndpointService = server.EndpointService - endpointHandler.EndpointGroupService = server.EndpointGroupService - endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager - var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer) - endpointGroupHandler.EndpointGroupService = server.EndpointGroupService - endpointGroupHandler.EndpointService = server.EndpointService - var registryHandler = handler.NewRegistryHandler(requestBouncer) - registryHandler.RegistryService = server.RegistryService - var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) - dockerHubHandler.DockerHubService = server.DockerHubService - var resourceHandler = handler.NewResourceHandler(requestBouncer) - resourceHandler.ResourceControlService = server.ResourceControlService - var uploadHandler = handler.NewUploadHandler(requestBouncer) - uploadHandler.FileService = server.FileService - 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 - stackHandler.RegistryService = server.RegistryService - stackHandler.DockerHubService = server.DockerHubService - var extensionHandler = handler.NewExtensionHandler(requestBouncer) - extensionHandler.EndpointService = server.EndpointService - extensionHandler.ProxyManager = proxyManager - var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) - storidgeHandler.EndpointService = server.EndpointService - storidgeHandler.EndpointGroupService = server.EndpointGroupService - storidgeHandler.TeamMembershipService = server.TeamMembershipService - storidgeHandler.ProxyManager = proxyManager server.Handler = &handler.Handler{ - AuthHandler: authHandler, - UserHandler: userHandler, - TeamHandler: teamHandler, - TeamMembershipHandler: teamMembershipHandler, - EndpointHandler: endpointHandler, - EndpointGroupHandler: endpointGroupHandler, - RegistryHandler: registryHandler, - DockerHubHandler: dockerHubHandler, - ResourceHandler: resourceHandler, - SettingsHandler: settingsHandler, - StatusHandler: statusHandler, - StackHandler: stackHandler, - TemplatesHandler: templatesHandler, - DockerHandler: dockerHandler, - WebSocketHandler: websocketHandler, - FileHandler: fileHandler, - UploadHandler: uploadHandler, - ExtensionHandler: extensionHandler, - StoridgeHandler: storidgeHandler, + AuthHandler: authHandler, + DockerHubHandler: dockerHubHandler, + EndpointGroupHandler: endpointGroupHandler, + EndpointHandler: endpointHandler, + EndpointProxyHandler: endpointProxyHandler, + FileHandler: fileHandler, + RegistryHandler: registryHandler, + ResourceControlHandler: resourceControlHandler, + SettingsHandler: settingsHandler, + StatusHandler: statusHandler, + StackHandler: stackHandler, + TagHandler: tagHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + TemplatesHandler: templatesHandler, + UploadHandler: uploadHandler, + UserHandler: userHandler, + WebSocketHandler: websocketHandler, } if server.SSL { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go new file mode 100644 index 000000000..5cf1264b6 --- /dev/null +++ b/api/libcompose/compose_stack.go @@ -0,0 +1,91 @@ +package libcompose + +import ( + "context" + "path" + "path/filepath" + + "github.com/portainer/libcompose/docker" + "github.com/portainer/libcompose/docker/client" + "github.com/portainer/libcompose/docker/ctx" + "github.com/portainer/libcompose/lookup" + "github.com/portainer/libcompose/project" + "github.com/portainer/libcompose/project/options" + "github.com/portainer/portainer" +) + +// ComposeStackManager represents a service for managing compose stacks. +type ComposeStackManager struct { + dataPath string +} + +// NewComposeStackManager initializes a new ComposeStackManager service. +func NewComposeStackManager(dataPath string) *ComposeStackManager { + return &ComposeStackManager{ + dataPath: dataPath, + } +} + +// Up will deploy a compose stack (equivalent of docker-compose up) +func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + ConfigDir: manager.dataPath, + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + EnvironmentLookup: &lookup.EnvfileLookup{ + Path: filepath.Join(stack.ProjectPath, ".env"), + }, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Up(context.Background(), options.Up{}) +} + +// Down will shutdown a compose stack (equivalent of docker-compose down) +func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true}) +} diff --git a/api/portainer.go b/api/portainer.go index ac9fd0c92..31b9c5203 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,7 +1,5 @@ package portainer -import "io" - type ( // Pair defines a key/value string pair Pair struct { @@ -73,12 +71,13 @@ type ( TemplatesURL string `json:"TemplatesURL"` LogoURL string `json:"LogoURL"` BlackListedLabels []Pair `json:"BlackListedLabels"` - DisplayDonationHeader bool `json:"DisplayDonationHeader"` DisplayExternalContributors bool `json:"DisplayExternalContributors"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` LDAPSettings LDAPSettings `json:"LDAPSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + // Deprecated fields + DisplayDonationHeader bool } // User represents a user account. @@ -130,16 +129,21 @@ type ( } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier). - StackID string + StackID int + + // StackType represents the type of the stack (compose v2, stack deploy v3). + StackType int // 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"` + ID StackID `json:"Id"` + Name string `json:"Name"` + Type StackType `json:"Type"` + EndpointID EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []Pair `json:"Env"` ProjectPath string - Env []Pair `json:"Env"` } // RegistryID represents a registry identifier. @@ -175,16 +179,18 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - Type EndpointType `json:"Type"` - URL string `json:"URL"` - GroupID EndpointGroupID `json:"GroupId"` - PublicURL string `json:"PublicURL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Extensions []EndpointExtension `json:"Extensions"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + Type EndpointType `json:"Type"` + URL string `json:"URL"` + GroupID EndpointGroupID `json:"GroupId"` + PublicURL string `json:"PublicURL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` + Tags []string `json:"Tags"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -194,6 +200,14 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // AzureCredentials represents the credentials used to connect to an Azure + // environment. + AzureCredentials struct { + ApplicationID string `json:"ApplicationID"` + TenantID string `json:"TenantID"` + AuthenticationKey string `json:"AuthenticationKey"` + } + // EndpointGroupID represents an endpoint group identifier. EndpointGroupID int @@ -204,7 +218,10 @@ type ( Description string `json:"Description"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Labels []Pair `json:"Labels"` + Tags []string `json:"Tags"` + + // Deprecated fields + Labels []Pair `json:"Labels"` } // EndpointExtension represents a extension associated to an endpoint. @@ -251,6 +268,15 @@ type ( AccessLevel ResourceAccessLevel `json:"AccessLevel"` } + // TagID represents a tag identifier. + TagID int + + // Tag represents a tag that can be associated to a resource. + Tag struct { + ID TagID + Name string `json:"Name"` + } + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int @@ -342,23 +368,24 @@ type ( // StackService represents a service for managing stack data. StackService interface { Stack(ID StackID) (*Stack, error) + StackByName(name string) (*Stack, error) Stacks() ([]Stack, error) - StacksBySwarmID(ID string) ([]Stack, error) CreateStack(stack *Stack) error UpdateStack(ID StackID, stack *Stack) error DeleteStack(ID StackID) error + GetNextIdentifier() int } // DockerHubService represents a service for managing the DockerHub object. DockerHubService interface { DockerHub() (*DockerHub, error) - StoreDockerHub(registry *DockerHub) error + UpdateDockerHub(registry *DockerHub) error } // SettingsService represents a service for managing application settings. SettingsService interface { Settings() (*Settings, error) - StoreSettings(settings *Settings) error + UpdateSettings(settings *Settings) error } // VersionService represents a service for managing version data. @@ -377,6 +404,13 @@ type ( DeleteResourceControl(ID ResourceControlID) error } + // TagService represents a service for managing tag data. + TagService interface { + Tags() ([]Tag, error) + CreateTag(tag *Tag) error + DeleteTag(ID TagID) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -401,18 +435,19 @@ type ( // FileService represents a service for managing files. FileService interface { GetFileContent(filePath string) (string, error) + Rename(oldPath, newPath string) error RemoveDirectory(directoryPath string) error - StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error + StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string - StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) - StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) + StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) WriteJSONToFile(path string, content interface{}) error + FileExists(path string) (bool, error) } // GitService represents a service for managing Git. @@ -432,20 +467,26 @@ type ( TestConnectivity(settings *LDAPSettings) error } - // StackManager represents a service to manage stacks. - StackManager interface { + // SwarmStackManager represents a service to manage Swarm stacks. + SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } + + // ComposeStackManager represents a service to manage Compose stacks. + ComposeStackManager interface { + Up(stack *Stack, endpoint *Endpoint) error + Down(stack *Stack, endpoint *Endpoint) error + } ) const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.17.1" + APIVersion = "1.18.0" // DBVersion is the version number of the Portainer database. - DBVersion = 11 + DBVersion = 12 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" // PortainerAgentHeader represents the name of the header available in any agent response @@ -530,4 +571,14 @@ const ( DockerEnvironment // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment AgentOnDockerEnvironment + // AzureEnvironment represents an endpoint connected to an Azure environment + AzureEnvironment +) + +const ( + _ StackType = iota + // DockerSwarmStack represents a stack managed via docker stack + DockerSwarmStack + // DockerComposeStack represents a stack managed via docker-compose + DockerComposeStack ) diff --git a/api/swagger.yaml b/api/swagger.yaml index 0f81812f1..086437dc3 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -50,13 +50,11 @@ info: Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API. - To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This - endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the - Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). + To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.17.1" + version: "1.18.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -69,6 +67,8 @@ tags: description: "Manage how Portainer connects to the DockerHub" - name: "endpoints" description: "Manage Docker environments" +- name: "endpoint_groups" + description: "Manage endpoint groups" - name: "registries" description: "Manage Docker registries" - name: "resource_controls" @@ -81,12 +81,16 @@ tags: description: "Manage Docker stacks" - name: "users" description: "Manage users" +- name: "tags" + description: "Manage tags" - name: "teams" description: "Manage teams" - name: "team_memberships" description: "Manage team memberships" - name: "templates" description: "Manage App Templates" +- name: "stacks" + description: "Manage stacks" - name: "upload" description: "Upload files" - name: "websocket" @@ -154,7 +158,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/DockerHubInspectResponse" + $ref: "#/definitions/DockerHubSubset" 500: description: "Server error" schema: @@ -181,6 +185,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/DockerHub" 400: description: "Invalid request" schema: @@ -233,11 +239,15 @@ paths: type: "string" description: "Name that will be used to identify this endpoint (example: my-endpoint)" required: true + - name: "EndpointType" + in: "formData" + type: "integer" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)" + required: true - name: "URL" in: "formData" type: "string" - description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375)" - required: true + description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Required if endpoint type is set to 1 or 2." - name: "PublicURL" in: "formData" type: "string" @@ -267,6 +277,18 @@ paths: in: "formData" type: "file" description: "TLS client key file" + - name: "AzureApplicationID" + in: "formData" + type: "string" + description: "Azure application ID. Required if endpoint type is set to 3" + - name: "AzureTenantID" + in: "formData" + type: "string" + description: "Azure tenant ID. Required if endpoint type is set to 3" + - name: "AzureAuthenticationKey" + in: "formData" + type: "string" + description: "Azure authentication key. Required if endpoint type is set to 3" responses: 200: description: "Success" @@ -397,7 +419,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -452,6 +474,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Endpoint" 400: description: "Invalid request" schema: @@ -470,76 +494,53 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" - - /endpoints/{endpointId}/stacks: + /endpoint_groups: get: tags: - - "stacks" - summary: "List stacks" + - "endpoint_groups" + summary: "List endpoint groups" description: | - List all stacks based on the current user authorizations. - Will return all stacks if using an administrator account otherwise it - will only return the list of stacks the user have access to. + List all endpoint groups based on the current user authorizations. Will + return all endpoint groups if using an administrator account otherwise it will + only return authorized endpoint groups. **Access policy**: restricted - operationId: "StackList" + operationId: "EndpointGroupList" produces: - "application/json" - parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + parameters: [] responses: 200: description: "Success" schema: - $ref: "#/definitions/StackListResponse" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Access denied to resource" + $ref: "#/definitions/EndpointGroupListResponse" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" post: tags: - - "stacks" - summary: "Deploy a new stack" + - "endpoint_groups" + summary: "Create a new endpoint" description: | - Deploy a new stack into a Docker environment specified via the endpoint identifier. - **Access policy**: restricted - operationId: "StackCreate" + Create a new endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupCreate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - - name: "method" - in: "query" - description: "Stack deployment method. Possible values: string or repository." - required: true - type: "string" - in: "body" name: "body" - description: "Stack details. Used when" + description: "Registry details" required: true schema: - $ref: "#/definitions/StackCreateRequest" + $ref: "#/definitions/EndpointGroupCreateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackCreateResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -547,44 +548,32 @@ paths: examples: application/json: err: "Invalid request data format" - 404: - description: "Endpoint not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Endpoint not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}: + /endpoint_groups/{id}: get: tags: - - "stacks" - summary: "Inspect a stack" + - "endpoint_groups" + summary: "Inspect an endpoint group" description: | - Retrieve details about a stack. - **Access policy**: restricted - operationId: "StackInspect" + Retrieve details abount an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupInspect" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "Endpoint group identifier" required: true - type: "string" + type: "integer" responses: 200: description: "Success" schema: - $ref: "#/definitions/Stack" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -592,53 +581,46 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" put: tags: - - "stacks" - summary: "Update a stack" + - "endpoint_groups" + summary: "Update an endpoint group" description: | - Update a stack. - **Access policy**: restricted - operationId: "StackUpdate" + Update an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupUpdate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" - in: "body" name: "body" - description: "Stack details" + description: "EndpointGroup details" required: true schema: - $ref: "#/definitions/StackUpdateRequest" + $ref: "#/definitions/EndpointGroupUpdateRequest" responses: 200: description: "Success" + schema: + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -647,37 +629,39 @@ paths: application/json: err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" delete: tags: - - "stacks" - summary: "Remove a stack" + - "endpoint_groups" + summary: "Remove an endpoint group" description: | - Remove a stack. - **Access policy**: restricted - operationId: "StackDelete" + Remove an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupDelete" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -686,66 +670,68 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}/stackfile: - get: + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" + /endpoint_groups/{id}/access: + put: tags: - - "stacks" - summary: "Retrieve the content of the Stack file for the specified stack" + - "endpoint_groups" + summary: "Manage accesses to an endpoint group" description: | - Get Stack file content. - **Access policy**: restricted - operationId: "StackFileInspect" + Manage user and team accesses to an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupAccessUpdate" + consumes: + - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" + - in: "body" + name: "body" + description: "Authorizations details" + required: true + schema: + $ref: "#/definitions/EndpointGroupAccessUpdateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackFileInspectResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" + err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: @@ -796,7 +782,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/RegistryCreateResponse" + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -882,6 +868,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -922,7 +910,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -970,6 +958,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -1011,6 +1001,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1064,6 +1056,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1104,7 +1098,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1174,6 +1168,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Settings" 400: description: "Invalid request" schema: @@ -1227,7 +1223,7 @@ paths: schema: $ref: "#/definitions/SettingsLDAPCheckRequest" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1261,6 +1257,305 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /stacks: + get: + tags: + - "stacks" + summary: "List stacks" + description: | + List all stacks based on the current user authorizations. + Will return all stacks if using an administrator account otherwise it + will only return the list of stacks the user have access to. + **Access policy**: restricted + operationId: "StackList" + produces: + - "application/json" + parameters: + - name: "filters" + in: "query" + description: | + Filters to process on the stack list. Encoded as JSON (a map[string]string). + For example, {"SwarmID": "jpofkc0i9uo9wtx1zesuk649w"} will only return stacks that are part + of the specified Swarm cluster. Available filters: EndpointID, SwarmID. + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackListResponse" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "stacks" + summary: "Deploy a new stack" + description: | + Deploy a new stack into a Docker environment specified via the endpoint identifier. + **Access policy**: restricted + operationId: "StackCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "type" + in: "query" + description: "Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack)." + required: true + type: "integer" + - name: "method" + in: "query" + description: "Stack deployment method. Possible values: file, string or repository." + required: true + type: "string" + - name: "endpointId" + in: "query" + description: "Identifier of the endpoint that will be used to deploy the stack." + required: true + type: "integer" + - in: "body" + name: "body" + description: "Stack details. Required when method equals string or repository." + schema: + $ref: "#/definitions/StackCreateRequest" + - name: "Name" + in: "formData" + type: "string" + description: "Name of the stack. Required when method equals file." + - name: "EndpointID" + in: "formData" + type: "string" + description: "Endpoint identifier used to deploy the stack. Required when method equals file." + - name: "SwarmID" + in: "formData" + type: "string" + description: "Swarm cluster identifier. Required when method equals file and type equals 1." + - name: "StackFileContent" + in: "formData" + type: "file" + description: "Stack file. Required when method equals file." + - name: "Env" + in: "formData" + type: "string" + description: "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}: + get: + tags: + - "stacks" + summary: "Inspect a stack" + description: | + Retrieve details about a stack. + **Access policy**: restricted + operationId: "StackInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "stacks" + summary: "Update a stack" + description: | + Update a stack. + **Access policy**: restricted + operationId: "StackUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - in: "body" + name: "body" + description: "Stack details" + required: true + schema: + $ref: "#/definitions/StackUpdateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "stacks" + summary: "Remove a stack" + description: | + Remove a stack. + **Access policy**: restricted + operationId: "StackDelete" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - name: "external" + in: "query" + description: "Set to true to delete an external stack. Only external Swarm stacks are supported." + type: "boolean" + - name: "endpointId" + in: "query" + description: "Endpoint identifier used to remove an external stack (required when external is set to true)" + type: "string" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}/file: + get: + tags: + - "stacks" + summary: "Retrieve the content of the Stack file for the specified stack" + description: | + Get Stack file content. + **Access policy**: restricted + operationId: "StackFileInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackFileInspectResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /users: get: tags: @@ -1306,7 +1601,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/UserCreateResponse" + $ref: "#/definitions/UserSubset" 400: description: "Invalid request" schema: @@ -1399,6 +1694,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1439,7 +1736,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1562,10 +1859,8 @@ paths: - "application/json" parameters: [] responses: - 200: + 204: description: "Success" - schema: - $ref: "#/definitions/UserListResponse" 404: description: "User not found" schema: @@ -1601,6 +1896,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1663,6 +1960,99 @@ paths: schema: $ref: "#/definitions/GenericError" + /tags: + get: + tags: + - "tags" + summary: "List tags" + description: | + List tags. + **Access policy**: administrator + operationId: "TagList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TagListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "tags" + summary: "Create a new tag" + description: | + Create a new tag. + **Access policy**: administrator + operationId: "TagCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Tag details" + required: true + schema: + $ref: "#/definitions/TagCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Tag" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 409: + description: "Conflict" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A tag with the specified name already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /tags/{id}: + delete: + tags: + - "tags" + summary: "Remove a tag" + description: | + Remove a tag. + **Access policy**: administrator + operationId: "TagDelete" + parameters: + - name: "id" + in: "path" + description: "Tag identifier" + required: true + type: "integer" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams: get: tags: @@ -1707,7 +2097,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamCreateResponse" + $ref: "#/definitions/Team" 400: description: "Invalid request" schema: @@ -1840,7 +2230,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1953,7 +2343,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamMembershipCreateResponse" + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2007,6 +2397,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2047,7 +2439,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -2114,6 +2506,17 @@ securityDefinitions: name: "Authorization" in: "header" definitions: + Tag: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Tag identifier" + Name: + type: "string" + example: "org/acme" + description: "Tag name" Team: type: "object" properties: @@ -2144,6 +2547,21 @@ definitions: type: "integer" example: 1 description: "Team role (1 for team leader and 2 for team member)" + UserSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "User identifier" + Username: + type: "string" + example: "bob" + description: "Username" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" User: type: "object" properties: @@ -2155,6 +2573,10 @@ definitions: type: "string" example: "bob" description: "Username" + Password: + type: "string" + example: "passwd" + description: "Password" Role: type: "integer" example: 1 @@ -2176,7 +2598,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.17.1" + example: "1.18.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -2187,10 +2609,6 @@ definitions: description: "URL to a logo that will be displayed on the login page as well\ \ as on top of the sidebar. Will use default Portainer logo when value is\ \ empty string" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2231,7 +2649,21 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" - + AzureCredentials: + type: "object" + properties: + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" LDAPSearchSettings: type: "object" properties: @@ -2294,10 +2726,6 @@ definitions: \ when querying containers" items: $ref: "#/definitions/Settings_BlackListedLabels" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2325,6 +2753,14 @@ definitions: value: type: "string" example: "bar" + Pair: + properties: + name: + type: "string" + example: "name" + value: + type: "string" + example: "value" Registry: type: "object" properties: @@ -2366,6 +2802,76 @@ definitions: type: "integer" example: 1 description: "Team identifier" + RegistrySubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Registry identifier" + Name: + type: "string" + example: "my-registry" + description: "Registry name" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "Team identifier" + EndpointGroup: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint group identifier" + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Description associated to the endpoint group" + description: "Endpoint group description" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint group. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "Team identifier" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" Endpoint: type: "object" properties: @@ -2380,7 +2886,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment." + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -2407,6 +2913,53 @@ definitions: type: "integer" example: 1 description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" + AzureCredentials: + $ref: "#/definitions/AzureCredentials" + EndpointSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint identifier" + Name: + type: "string" + example: "my-endpoint" + description: "Endpoint name" + Type: + type: "integer" + example: 1 + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of the Docker host associated to this endpoint" + PublicURL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address where exposed containers will be reachable" + GroupID: + type: "integer" + example: 1 + description: "Endpoint group identifier" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" GenericError: type: "object" properties: @@ -2435,7 +2988,18 @@ definitions: type: "string" example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE" description: "JWT token used to authenticate against the API" - DockerHubInspectResponse: + DockerHubSubset: + type: "object" + properties: + Authentication: + type: "boolean" + example: true + description: "Is authentication against DockerHub enabled" + Username: + type: "string" + example: "hub_user" + description: "Username used to authenticate against the DockerHub" + DockerHub: type: "object" properties: Authentication: @@ -2450,6 +3014,45 @@ definitions: type: "string" example: "hub_password" description: "Password used to authenticate against the DockerHub" + ResourceControl: + type: "object" + properties: + ResourceID: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier on which access control will be applied.\ + \ In the case of a resource control applied to a stack, use the stack name as identifier" + Type: + type: "string" + example: "container" + description: "Type of Docker resource. Valid values are: container, volume\ + \ service, secret, config or stack" + AdministratorsOnly: + type: "boolean" + example: true + description: "Restrict access to the associated resource to administrators\ + \ only" + Users: + type: "array" + description: "List of user identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "User identifier" + Teams: + type: "array" + description: "List of team identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "Team identifier" + SubResourceIDs: + type: "array" + description: "List of Docker resources that will inherit this access control" + items: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier" DockerHubUpdateRequest: type: "object" required: @@ -2472,7 +3075,11 @@ definitions: EndpointListResponse: type: "array" items: - $ref: "#/definitions/Endpoint" + $ref: "#/definitions/EndpointSubset" + EndpointGroupListResponse: + type: "array" + items: + $ref: "#/definitions/EndpointGroup" EndpointUpdateRequest: type: "object" properties: @@ -2489,6 +3096,10 @@ definitions: example: "docker.mydomain.tld:2375" description: "URL or IP address where exposed containers will be reachable.\ \ Defaults to URL if not specified" + GroupID: + type: "integer" + example: "1" + description: "Group identifier" TLS: type: "boolean" example: true @@ -2501,6 +3112,18 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" EndpointAccessUpdateRequest: type: "object" properties: @@ -2518,6 +3141,23 @@ definitions: type: "integer" example: 1 description: "Team identifier" + EndpointGroupAccessUpdateRequest: + type: "object" + properties: + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" RegistryCreateRequest: type: "object" required: @@ -2547,17 +3187,10 @@ definitions: type: "string" example: "registry_password" description: "Password used to authenticate against this registry" - RegistryCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the registry" RegistryListResponse: type: "array" items: - $ref: "#/definitions/Registry" + $ref: "#/definitions/RegistrySubset" RegistryUpdateRequest: type: "object" required: @@ -2688,10 +3321,6 @@ definitions: \ when querying containers" items: $ref: "#/definitions/Settings_BlackListedLabels" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2711,6 +3340,52 @@ definitions: type: "boolean" example: true description: "Whether non-administrator users should be able to use privileged mode when creating containers" + EndpointGroupCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" + EndpointGroupUpdateRequest: + type: "object" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" UserCreateRequest: type: "object" required: @@ -2730,17 +3405,10 @@ definitions: type: "integer" example: 1 description: "User role (1 for administrator account and 2 for regular account)" - UserCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the user" UserListResponse: type: "array" items: - $ref: "#/definitions/User" + $ref: "#/definitions/UserSubset" UserUpdateRequest: type: "object" properties: @@ -2772,6 +3440,19 @@ definitions: type: "boolean" example: true description: "Is the password valid" + TagListResponse: + type: "array" + items: + $ref: "#/definitions/Tag" + TagCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "org/acme" + description: "Name" TeamCreateRequest: type: "object" required: @@ -2781,13 +3462,6 @@ definitions: type: "string" example: "developers" description: "Name" - TeamCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team" TeamListResponse: type: "array" items: @@ -2825,13 +3499,6 @@ definitions: type: "integer" example: 1 description: "Role for the user inside the team (1 for leader and 2 for regular member)" - TeamMembershipCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team membership" TeamMembershipListResponse: type: "array" items: @@ -2898,7 +3565,6 @@ definitions: type: "object" required: - "Name" - - "SwarmID" properties: Name: type: "string" @@ -2907,7 +3573,7 @@ definitions: SwarmID: type: "string" example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Cluster identifier of the Swarm cluster" + description: "Swarm cluster identifier. Required when creating a Swarm stack (type 1)." StackFileContent: type: "string" example: "version: 3\n services:\n web:\n image:nginx" @@ -2919,7 +3585,7 @@ definitions: ComposeFilePathInRepository: type: "string" example: "docker-compose.yml" - description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method." + description: "Path to the Stack file inside the Git repository. Will default to 'docker-compose.yml' if not specified." RepositoryAuthentication: type: "boolean" example: true @@ -2945,13 +3611,6 @@ definitions: value: type: "string" example: "password" - StackCreateResponse: - type: "object" - properties: - Id: - type: "string" - example: "myStack_jpofkc0i9uo9wtx1zesuk649w" - description: "Id of the stack" StackListResponse: type: "array" items: @@ -2967,6 +3626,14 @@ definitions: type: "string" example: "myStack" description: "Stack name" + Type: + type: "integer" + example: "1" + description: "Stack type. 1 for a Swarm stack, 2 for a Compose stack" + EndpointID: + type: "integer" + example: "1" + description: "Endpoint identifier. Reference the endpoint that will be used for deployment " EntryPoint: type: "string" example: "docker-compose.yml" diff --git a/app/__module.js b/app/__module.js index f5ebe25a2..e1cf659fa 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,6 +1,7 @@ angular.module('portainer', [ 'ui.bootstrap', 'ui.router', + 'ui.select', 'isteven-multi-select', 'ngCookies', 'ngSanitize', @@ -18,6 +19,7 @@ angular.module('portainer', [ 'portainer.templates', 'portainer.app', 'portainer.agent', + 'portainer.azure', 'portainer.docker', 'extension.storidge', 'rzModule']); diff --git a/app/azure/_module.js b/app/azure/_module.js new file mode 100644 index 000000000..23693574c --- /dev/null +++ b/app/azure/_module.js @@ -0,0 +1,49 @@ +angular.module('portainer.azure', ['portainer.app']) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var azure = { + name: 'azure', + url: '/azure', + parent: 'root', + abstract: true + }; + + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController' + } + } + }; + + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController' + } + } + }; + + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: 'app/azure/views/dashboard/dashboard.html', + controller: 'AzureDashboardController' + } + } + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); +}]); diff --git a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js new file mode 100644 index 000000000..b5af18fb7 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js @@ -0,0 +1,8 @@ +angular.module('portainer.azure').component('azureEndpointConfig', { + bindings: { + applicationId: '=', + tenantId: '=', + authenticationKey: '=' + }, + templateUrl: 'app/azure/components/azure-endpoint-config/azureEndpointConfig.html' +}); diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html new file mode 100644 index 000000000..c0d839102 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html @@ -0,0 +1,29 @@ +
+
+ Azure configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
diff --git a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js new file mode 100644 index 000000000..d1f9230f4 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js @@ -0,0 +1,3 @@ +angular.module('portainer.azure').component('azureSidebarContent', { + templateUrl: 'app/azure/components/azure-sidebar-content/azureSidebarContent.html' +}); diff --git a/app/azure/components/azure-sidebar-content/azureSidebarContent.html b/app/azure/components/azure-sidebar-content/azureSidebarContent.html new file mode 100644 index 000000000..01986e8e7 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azureSidebarContent.html @@ -0,0 +1,6 @@ + + diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html new file mode 100644 index 000000000..f1113aadb --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -0,0 +1,104 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ + Search + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Location + + + + + Published Ports +
+ + + + + {{ item.Name | truncate:50 }} + {{ item.Location }} + + :{{ p.port }} + + - +
Loading...
No container available.
+
+ +
+
+
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js new file mode 100644 index 000000000..c83550ca5 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.azure').component('containergroupsDatatable', { + templateUrl: 'app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + removeAction: '<' + } +}); diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js new file mode 100644 index 000000000..0ba06a690 --- /dev/null +++ b/app/azure/models/container_group.js @@ -0,0 +1,66 @@ +function ContainerGroupDefaultModel() { + this.Location = ''; + this.OSType = 'Linux'; + this.Name = ''; + this.Image = ''; + this.AllocatePublicIP = true; + this.Ports = [ + { + container: 80, + host: 80, + protocol: 'TCP' + } + ]; + this.CPU = 1; + this.Memory = 1; +} + +function ContainerGroupViewModel(data, subscriptionId, resourceGroupName) { + this.Id = data.id; + this.Name = data.name; + this.Location = data.location; + this.IPAddress = data.properties.ipAddress.ip; + this.Ports = data.properties.ipAddress.ports; +} + +function CreateContainerGroupRequest(model) { + this.location = model.Location; + + var containerPorts = []; + var addressPorts = []; + for (var i = 0; i < model.Ports.length; i++) { + var binding = model.Ports[i]; + + containerPorts.push({ + port: binding.container + }); + + addressPorts.push({ + port: binding.host, + protocol: binding.protocol + }); + } + + this.properties = { + osType: model.OSType, + containers: [ + { + name: model.Name, + properties: { + image: model.Image, + ports: containerPorts, + resources: { + requests: { + cpu: model.CPU, + memoryInGB: model.Memory + } + } + } + } + ], + ipAddress: { + type: model.AllocatePublicIP ? 'Public': 'Private', + ports: addressPorts + } + }; +} diff --git a/app/azure/models/location.js b/app/azure/models/location.js new file mode 100644 index 000000000..a010776ba --- /dev/null +++ b/app/azure/models/location.js @@ -0,0 +1,6 @@ +function LocationViewModel(data) { + this.Id = data.id; + this.SubscriptionId = data.subscriptionId; + this.DisplayName = data.displayName; + this.Name = data.name; +} diff --git a/app/azure/models/provider.js b/app/azure/models/provider.js new file mode 100644 index 000000000..48cca79e9 --- /dev/null +++ b/app/azure/models/provider.js @@ -0,0 +1,7 @@ +function ContainerInstanceProviderViewModel(data) { + this.Id = data.id; + this.Namespace = data.namespace; + + var containerGroupType = _.find(data.resourceTypes, { 'resourceType': 'containerGroups' }); + this.Locations = containerGroupType.locations; +} diff --git a/app/azure/models/resource_group.js b/app/azure/models/resource_group.js new file mode 100644 index 000000000..aa04f1809 --- /dev/null +++ b/app/azure/models/resource_group.js @@ -0,0 +1,6 @@ +function ResourceGroupViewModel(data, subscriptionId) { + this.Id = data.id; + this.SubscriptionId = subscriptionId; + this.Name = data.name; + this.Location = data.location; +} diff --git a/app/azure/models/subscription.js b/app/azure/models/subscription.js new file mode 100644 index 000000000..2baa0da4d --- /dev/null +++ b/app/azure/models/subscription.js @@ -0,0 +1,4 @@ +function SubscriptionViewModel(data) { + this.Id = data.subscriptionId; + this.Name = data.displayName; +} diff --git a/app/azure/rest/azure.js b/app/azure/rest/azure.js new file mode 100644 index 000000000..2621c7d50 --- /dev/null +++ b/app/azure/rest/azure.js @@ -0,0 +1,17 @@ +angular.module('portainer.azure') +.factory('Azure', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function AzureFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var service = {}; + + service.delete = function(id, apiVersion) { + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/azure' + id + '?api-version=' + apiVersion; + return $http({ + method: 'DELETE', + url: url + }); + }; + + return service; +}]); diff --git a/app/azure/rest/container_group.js b/app/azure/rest/container_group.js new file mode 100644 index 000000000..76ed5c4dc --- /dev/null +++ b/app/azure/rest/container_group.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.factory('ContainerGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ContainerGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var resource = {}; + + var base = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance/containerGroups', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + } + ); + + var withResourceGroup = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourceGroups/:resourceGroupName/providers/Microsoft.ContainerInstance/containerGroups/:containerGroupName', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + create: { + method: 'PUT', + params: { + subscriptionId: '@subscriptionId', + resourceGroupName: '@resourceGroupName', + containerGroupName: '@containerGroupName' + } + } + } + ); + + resource.query = base.query; + resource.create = withResourceGroup.create; + + return resource; +}]); diff --git a/app/azure/rest/location.js b/app/azure/rest/location.js new file mode 100644 index 000000000..9516761c6 --- /dev/null +++ b/app/azure/rest/location.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Location', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function LocationFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/locations', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/provider.js b/app/azure/rest/provider.js new file mode 100644 index 000000000..e1a848182 --- /dev/null +++ b/app/azure/rest/provider.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Provider', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ProviderFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/:providerNamespace', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + get: { method: 'GET', params: { subscriptionId: '@subscriptionId', providerNamespace: '@providerNamespace' } } + }); +}]); diff --git a/app/azure/rest/resource_group.js b/app/azure/rest/resource_group.js new file mode 100644 index 000000000..2147682e3 --- /dev/null +++ b/app/azure/rest/resource_group.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('ResourceGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ResourceGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/subscription.js b/app/azure/rest/subscription.js new file mode 100644 index 000000000..5b30974c6 --- /dev/null +++ b/app/azure/rest/subscription.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Subscription', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function SubscriptionFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET' } + }); +}]); diff --git a/app/azure/services/azureService.js b/app/azure/services/azureService.js new file mode 100644 index 000000000..8d7def765 --- /dev/null +++ b/app/azure/services/azureService.js @@ -0,0 +1,66 @@ +angular.module('portainer.azure') +.factory('AzureService', ['$q', 'Azure', 'SubscriptionService', 'ResourceGroupService', 'ContainerGroupService', 'ProviderService', +function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) { + 'use strict'; + var service = {}; + + service.deleteContainerGroup = function(id) { + return Azure.delete(id, '2018-04-01'); + }; + + service.createContainerGroup = function(model, subscriptionId, resourceGroupName) { + return ContainerGroupService.create(model, subscriptionId, resourceGroupName); + }; + + service.subscriptions = function() { + return SubscriptionService.subscriptions(); + }; + + service.containerInstanceProvider = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider); + }; + + service.resourceGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups); + }; + + service.containerGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups); + }; + + service.aggregate = function(resourcesBySubcription) { + var aggregatedResources = []; + Object.keys(resourcesBySubcription).forEach(function(key, index) { + aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]); + }); + return aggregatedResources; + }; + + function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) { + var deferred = $q.defer(); + + var resources = {}; + + var resourceQueries = []; + for (var i = 0; i < subscriptions.length; i++) { + var subscription = subscriptions[i]; + resourceQueries.push(resourceQuery(subscription.Id)); + } + + $q.all(resourceQueries) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var result = data[i]; + resources[subscriptions[i].Id] = result; + } + deferred.resolve(resources); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resources', err: err }); + }); + + return deferred.promise; + } + + return service; +}]); diff --git a/app/azure/services/containerGroupService.js b/app/azure/services/containerGroupService.js new file mode 100644 index 000000000..96031a587 --- /dev/null +++ b/app/azure/services/containerGroupService.js @@ -0,0 +1,33 @@ +angular.module('portainer.azure') +.factory('ContainerGroupService', ['$q', 'ContainerGroup', function ContainerGroupServiceFactory($q, ContainerGroup) { + 'use strict'; + var service = {}; + + service.containerGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ContainerGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var containerGroups = data.value.map(function (item) { + return new ContainerGroupViewModel(item); + }); + deferred.resolve(containerGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve container groups', err: err }); + }); + + return deferred.promise; + }; + + service.create = function(model, subscriptionId, resourceGroupName) { + var payload = new CreateContainerGroupRequest(model); + return ContainerGroup.create({ + subscriptionId: subscriptionId, + resourceGroupName: resourceGroupName, + containerGroupName: model.Name + }, payload).$promise; + }; + + return service; +}]); diff --git a/app/azure/services/locationService.js b/app/azure/services/locationService.js new file mode 100644 index 000000000..547ed93b3 --- /dev/null +++ b/app/azure/services/locationService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('LocationService', ['$q', 'Location', function LocationServiceFactory($q, Location) { + 'use strict'; + var service = {}; + + service.locations = function(subscriptionId) { + var deferred = $q.defer(); + + Location.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var locations = data.value.map(function (item) { + return new LocationViewModel(item); + }); + deferred.resolve(locations); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve locations', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/providerService.js b/app/azure/services/providerService.js new file mode 100644 index 000000000..88451d4f5 --- /dev/null +++ b/app/azure/services/providerService.js @@ -0,0 +1,22 @@ +angular.module('portainer.azure') +.factory('ProviderService', ['$q', 'Provider', function ProviderServiceFactory($q, Provider) { + 'use strict'; + var service = {}; + + service.containerInstanceProvider = function(subscriptionId) { + var deferred = $q.defer(); + + Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' }).$promise + .then(function success(data) { + var provider = new ContainerInstanceProviderViewModel(data); + deferred.resolve(provider); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve provider', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js new file mode 100644 index 000000000..1777edea8 --- /dev/null +++ b/app/azure/services/resourceGroupService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('ResourceGroupService', ['$q', 'ResourceGroup', function ResourceGroupServiceFactory($q, ResourceGroup) { + 'use strict'; + var service = {}; + + service.resourceGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ResourceGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var resourceGroups = data.value.map(function (item) { + return new ResourceGroupViewModel(item, subscriptionId); + }); + deferred.resolve(resourceGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resource groups', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/subscriptionService.js b/app/azure/services/subscriptionService.js new file mode 100644 index 000000000..f468e1c8e --- /dev/null +++ b/app/azure/services/subscriptionService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('SubscriptionService', ['$q', 'Subscription', function SubscriptionServiceFactory($q, Subscription) { + 'use strict'; + var service = {}; + + service.subscriptions = function() { + var deferred = $q.defer(); + + Subscription.query({}).$promise + .then(function success(data) { + var subscriptions = data.value.map(function (item) { + return new SubscriptionViewModel(item); + }); + deferred.resolve(subscriptions); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/views/containerinstances/containerInstancesController.js b/app/azure/views/containerinstances/containerInstancesController.js new file mode 100644 index 000000000..ecf2c40cd --- /dev/null +++ b/app/azure/views/containerinstances/containerInstancesController.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.controller('AzureContainerInstancesController', ['$scope', '$state', 'AzureService', 'Notifications', +function ($scope, $state, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + return AzureService.containerGroups(subscriptions); + }) + .then(function success(data) { + $scope.containerGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load container groups'); + }); + } + + $scope.deleteAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (item) { + AzureService.deleteContainerGroup(item.Id) + .then(function success() { + Notifications.success('Container group successfully removed', item.Name); + var index = $scope.containerGroups.indexOf(item); + $scope.containerGroups.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container group'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + initView(); +}]); diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html new file mode 100644 index 000000000..af3990f8e --- /dev/null +++ b/app/azure/views/containerinstances/containerinstances.html @@ -0,0 +1,19 @@ + + + + + + + Container instances + + +
+
+ +
+
diff --git a/app/azure/views/containerinstances/create/createContainerInstanceController.js b/app/azure/views/containerinstances/create/createContainerInstanceController.js new file mode 100644 index 000000000..b3a7ed173 --- /dev/null +++ b/app/azure/views/containerinstances/create/createContainerInstanceController.js @@ -0,0 +1,87 @@ +angular.module('portainer.azure') +.controller('AzureCreateContainerInstanceController', ['$q', '$scope', '$state', 'AzureService', 'Notifications', +function ($q, $scope, $state, AzureService, Notifications) { + + var allResourceGroups = []; + var allProviders = []; + + $scope.state = { + actionInProgress: false, + selectedSubscription: null, + selectedResourceGroup: null + }; + + $scope.changeSubscription = function() { + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders); + }; + + $scope.addPortBinding = function() { + $scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' }); + }; + + $scope.removePortBinding = function(index) { + $scope.model.Ports.splice(index, 1); + }; + + $scope.create = function() { + var model = $scope.model; + var subscriptionId = $scope.state.selectedSubscription.Id; + var resourceGroupName = $scope.state.selectedResourceGroup.Name; + + $scope.state.actionInProgress = true; + AzureService.createContainerGroup(model, subscriptionId, resourceGroupName) + .then(function success(data) { + Notifications.success('Container successfully created', model.Name); + $state.go('azure.containerinstances'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) { + $scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0]; + $scope.resourceGroups = resourceGroups[subscription.Id]; + + var currentSubLocations = providers[subscription.Id].Locations; + $scope.model.Location = currentSubLocations[0]; + $scope.locations = currentSubLocations; + } + + function initView() { + var model = new ContainerGroupDefaultModel(); + + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.state.selectedSubscription = subscriptions[0]; + $scope.subscriptions = subscriptions; + + return $q.all({ + resourceGroups: AzureService.resourceGroups(subscriptions), + containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions) + }); + }) + .then(function success(data) { + var resourceGroups = data.resourceGroups; + allResourceGroups = resourceGroups; + + var containerInstancesProviders = data.containerInstancesProviders; + allProviders = containerInstancesProviders; + + $scope.model = model; + + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve Azure resources'); + }); + } + + initView(); +}]); diff --git a/app/azure/views/containerinstances/create/createcontainerinstance.html b/app/azure/views/containerinstances/create/createcontainerinstance.html new file mode 100644 index 000000000..54346eef6 --- /dev/null +++ b/app/azure/views/containerinstances/create/createcontainerinstance.html @@ -0,0 +1,160 @@ + + + + Container instances > Add container + + + +
+
+ + +
+
+ Azure settings +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ Container configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + + map additional port + +
+ +
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ +
+ Container resources +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
+
+
+
+
diff --git a/app/azure/views/dashboard/dashboard.html b/app/azure/views/dashboard/dashboard.html new file mode 100644 index 000000000..eaa60a53e --- /dev/null +++ b/app/azure/views/dashboard/dashboard.html @@ -0,0 +1,33 @@ + + + Dashboard + + + diff --git a/app/azure/views/dashboard/dashboardController.js b/app/azure/views/dashboard/dashboardController.js new file mode 100644 index 000000000..f24ff4e29 --- /dev/null +++ b/app/azure/views/dashboard/dashboardController.js @@ -0,0 +1,21 @@ +angular.module('portainer.azure') +.controller('AzureDashboardController', ['$scope', 'AzureService', 'Notifications', +function ($scope, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.subscriptions = subscriptions; + return AzureService.resourceGroups(subscriptions); + }) + .then(function success(data) { + $scope.resourceGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load dashboard data'); + }); + } + + initView(); +}]); diff --git a/app/constants.js b/app/constants.js index 8a16181f7..c4f911034 100644 --- a/app/constants.js +++ b/app/constants.js @@ -6,8 +6,10 @@ angular.module('portainer') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SETTINGS', 'api/settings') +.constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') .constant('API_ENDPOINT_USERS', 'api/users') +.constant('API_ENDPOINT_TAGS', 'api/tags') .constant('API_ENDPOINT_TEAMS', 'api/teams') .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') diff --git a/app/docker/__module.js b/app/docker/__module.js index 28ad5e52d..82041efeb 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -49,9 +49,6 @@ angular.module('portainer.docker', ['portainer.app']) templateUrl: 'app/docker/views/containers/containers.html', controller: 'ContainersController' } - }, - params: { - selectedContainers: [] } }; @@ -314,39 +311,6 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var stacks = { - name: 'docker.stacks', - url: '/stacks', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/stacks.html', - controller: 'StacksController' - } - } - }; - - var stack = { - name: 'docker.stacks.stack', - url: '/:id', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/edit/stack.html', - controller: 'StackController' - } - } - }; - - var stackCreation = { - name: 'docker.stacks.new', - url: '/new', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/create/createstack.html', - controller: 'CreateStackController' - } - } - }; - var swarm = { name: 'docker.swarm', url: '/swarm', @@ -489,9 +453,6 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(service); $stateRegistryProvider.register(serviceCreation); $stateRegistryProvider.register(serviceLogs); - $stateRegistryProvider.register(stacks); - $stateRegistryProvider.register(stack); - $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(swarm); $stateRegistryProvider.register(swarmVisualizer); $stateRegistryProvider.register(tasks); diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html index 7012bb15c..8d8cafc34 100644 --- a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html @@ -1,5 +1,5 @@ - + diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index f41830f44..76b5ec683 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.js b/app/docker/components/datatables/configs-datatable/configsDatatable.js index 66f774419..5b31c965e 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.js +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('configsDatatable', { templateUrl: 'app/docker/components/datatables/configs-datatable/configsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index 5eddcae40..0b4ba9454 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js index 2b294141b..d807b7019 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { templateUrl: 'app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html index 004f9721f..3284f3e22 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js index 51afee8be..621ff7bf3 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containerProcessesDatatable', { templateUrl: 'app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '=', headerset: '<', diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html new file mode 100644 index 000000000..37bc895ed --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html @@ -0,0 +1,35 @@ +
+
+ + + + + + + +
+ +
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js new file mode 100644 index 000000000..996b5e2c6 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('containersDatatableActions', { + templateUrl: 'app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html', + controller: 'ContainersDatatableActionsController', + bindings: { + selectedItems: '=', + selectedItemCount: '=', + noStoppedItemsSelected: '=', + noRunningItemsSelected: '=', + noPausedItemsSelected: '=', + showAddAction: '<' + } +}); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js new file mode 100644 index 000000000..94ce439b3 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -0,0 +1,104 @@ +angular.module('portainer.docker') +.controller('ContainersDatatableActionsController', ['$state', 'ContainerService', 'ModalService', 'Notifications', 'HttpRequestHelper', +function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) { + this.startAction = function(selectedItems) { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); + }; + + this.stopAction = function(selectedItems) { + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); + }; + + this.restartAction = function(selectedItems) { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); + }; + + this.killAction = function(selectedItems) { + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); + }; + + this.pauseAction = function(selectedItems) { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); + }; + + this.resumeAction = function(selectedItems) { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); + }; + + this.removeAction = function(selectedItems) { + var isOneContainerRunning = false; + for (var i = 0; i < selectedItems.length; i++) { + var container = selectedItems[i]; + if (container.State === 'running') { + isOneContainerRunning = true; + break; + } + } + + var title = 'You are about to remove one or more container.'; + if (isOneContainerRunning) { + title = 'You are about to remove one or more running container.'; + } + + ModalService.confirmContainerDeletion(title, function (result) { + if(!result) { return; } + var cleanVolumes = false; + if (result[0]) { + cleanVolumes = true; + } + removeSelectedContainers(selectedItems, cleanVolumes); + } + ); + }; + + function executeActionOnContainerList(containers, action, successMessage, errorMessage) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + action(container.Id) + .then(function success() { + Notifications.success(successMessage, container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, errorMessage); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function removeSelectedContainers(containers, cleanVolumes) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + ContainerService.remove(container, cleanVolumes) + .then(function success() { + Notifications.success('Container successfully removed', container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } +}]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 9ce58d5f7..cfaae7d8c 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
@@ -51,41 +51,14 @@
-
-
- - - - - - - -
- -
+
diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js index 991c328ec..58e54f134 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('tasksDatatable', { templateUrl: 'app/docker/components/datatables/tasks-datatable/tasksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 3d896409c..1a271dfaa 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index 8d1707fe5..da8119da9 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('volumesDatatable', { templateUrl: 'app/docker/components/datatables/volumes-datatable/volumesDatatable.html', controller: 'VolumesDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index ba0001c9c..59da699dc 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -7,8 +7,8 @@ LinuxServer.io
-
{{ item.Status }} @@ -205,7 +178,7 @@ {{ item.IP ? item.IP : '-' }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ p.public }}:{{ p.private }} - diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 4e806b415..5fc7584ce 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containersDatatable', { templateUrl: 'app/docker/components/datatables/containers-datatable/containersDatatable.html', controller: 'ContainersDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', @@ -11,14 +11,6 @@ angular.module('portainer.docker').component('containersDatatable', { showTextFilter: '<', showOwnershipColumn: '<', showHostColumn: '<', - publicUrl: '<', - containerNameTruncateSize: '<', - startAction: '<', - stopAction: '<', - killAction: '<', - restartAction: '<', - pauseAction: '<', - resumeAction: '<', - removeAction: '<' + showAddAction: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index d0c4bc27e..fe97f901a 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -1,7 +1,6 @@ angular.module('portainer.docker') -.controller('ContainersDatatableController', ['PaginationService', 'DatatableService', -function (PaginationService, DatatableService) { - +.controller('ContainersDatatableController', ['PaginationService', 'DatatableService', 'EndpointProvider', +function (PaginationService, DatatableService, EndpointProvider) { var ctrl = this; this.state = { @@ -10,7 +9,11 @@ function (PaginationService, DatatableService) { paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), displayTextFilter: false, selectedItemCount: 0, - selectedItems: [] + selectedItems: [], + noStoppedItemsSelected: true, + noRunningItemsSelected: true, + noPausedItemsSelected: true, + publicURL: EndpointProvider.endpointPublicURL() }; this.settings = { @@ -139,12 +142,9 @@ function (PaginationService, DatatableService) { var availableStateFilters = []; for (var i = 0; i < this.dataset.length; i++) { var item = this.dataset[i]; - if (item.Checked) { - this.selectItem(item); - } availableStateFilters.push({ label: item.Status, display: true }); } - this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); }; this.updateStoredFilters = function(storedFilters) { diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.html b/app/docker/components/datatables/events-datatable/eventsDatatable.html index b9554be4c..90625950b 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.html +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.js b/app/docker/components/datatables/events-datatable/eventsDatatable.js index 9db4141fe..d81d10314 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.js +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('eventsDatatable', { templateUrl: 'app/docker/components/datatables/events-datatable/eventsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index bded973b1..6816f375b 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js index d2e5305d4..e3d0477a5 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('imagesDatatable', { templateUrl: 'app/docker/components/datatables/images-datatable/imagesDatatable.html', controller: 'ImagesDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 6ccf496f6..eee23d6d4 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index 1f7527890..5bd04f1aa 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('networksDatatable', { templateUrl: 'app/docker/components/datatables/networks-datatable/networksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html index 22d3d7cbf..f5d6aa285 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js index 9a022baa5..6df455b9c 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('nodeTasksDatatable', { templateUrl: 'app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index ab825e5fa..1dc48abd1 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.js b/app/docker/components/datatables/nodes-datatable/nodesDatatable.js index dbf8d2d87..d3e42449b 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.js +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('nodesDatatable', { templateUrl: 'app/docker/components/datatables/nodes-datatable/nodesDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index 300466505..6aecaf077 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js index 7c55738a9..e572901f4 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('secretsDatatable', { templateUrl: 'app/docker/components/datatables/secrets-datatable/secretsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html new file mode 100644 index 000000000..71ee75b3f --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html @@ -0,0 +1,82 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + Status + + + + + Filter + Filter + + + TaskActions + + Slot + + + + + + Node + + + + + + Last Update + + + +
+ {{ item.Status.State }} + + {{ item.Id }}Roz + {{ item.Id }}Doz + +
+ + + +
+
{{ item.Slot ? item.Slot : '-' }}{{ item.NodeId | tasknodename: $ctrl.nodes }}{{ item.Updated | getisodate }}
No task matching filter.
+
diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js new file mode 100644 index 000000000..ce15b64db --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js @@ -0,0 +1,15 @@ +angular.module('portainer.docker').component('serviceTasksDatatable', { + templateUrl: 'app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html', + controller: 'ServiceTasksDatatableController', + bindings: { + dataset: '<', + serviceId: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + nodes: '<', + agentProxy: '<', + textFilter: '=', + showTaskLogsButton: '<' + } +}); diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js new file mode 100644 index 000000000..33eecffc4 --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -0,0 +1,70 @@ +angular.module('portainer.docker') +.controller('ServiceTasksDatatableController', ['DatatableService', +function (DatatableService) { + var ctrl = this; + + this.state = { + orderBy: this.orderBy + }; + + this.filters = { + state: { + open: false, + enabled: false, + values: [] + } + }; + + this.applyFilters = function(item, index, array) { + var filters = ctrl.filters; + for (var i = 0; i < filters.state.values.length; i++) { + var filter = filters.state.values[i]; + if (item.Status.State === filter.label && filter.display) { + return true; + } + } + return false; + }; + + this.onStateFilterChange = function() { + var filters = this.filters.state.values; + var filtered = false; + for (var i = 0; i < filters.length; i++) { + var filter = filters[i]; + if (!filter.display) { + filtered = true; + } + } + this.filters.state.enabled = filtered; + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); + }; + + this.prepareTableFromDataset = function() { + var availableStateFilters = []; + for (var i = 0; i < this.dataset.length; i++) { + var item = this.dataset[i]; + availableStateFilters.push({ label: item.Status.State, display: true }); + } + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + }; + + this.$onInit = function() { + setDefaults(this); + this.prepareTableFromDataset(); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + }; + + function setDefaults(ctrl) { + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } +}]); diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html new file mode 100644 index 000000000..4ca83adcb --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html @@ -0,0 +1,15 @@ +
+
+ + +
+ +
diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js new file mode 100644 index 000000000..32c16717f --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('servicesDatatableActions', { + templateUrl: 'app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html', + controller: 'ServicesDatatableActionsController', + bindings: { + selectedItems: '=', + selectedItemCount: '=', + showUpdateAction: '<', + showAddAction: '<' + } +}); diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js new file mode 100644 index 000000000..e2a394e58 --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js @@ -0,0 +1,89 @@ +angular.module('portainer.docker') +.controller('ServicesDatatableActionsController', ['$state', 'ServiceService', 'ServiceHelper', 'Notifications', 'ModalService', 'ImageHelper', +function ($state, ServiceService, ServiceHelper, Notifications, ModalService, ImageHelper) { + + this.scaleAction = function scaleService(service) { + var config = ServiceHelper.serviceToConfig(service.Model); + config.Mode.Replicated.Replicas = service.Replicas; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to scale service'); + service.Scale = false; + service.Replicas = service.ReplicaCount; + }); + }; + + this.removeAction = function(selectedItems) { + ModalService.confirmDeletion( + 'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + removeServices(selectedItems); + } + ); + }; + + this.updateAction = function(selectedItems) { + ModalService.confirmServiceForceUpdate( + 'Do you want to force an update of the selected service(s)? All the tasks associated to the selected service(s) will be recreated.', + function (result) { + if(!result) { return; } + var pullImage = false; + if (result[0]) { + pullImage = true; + } + forceUpdateServices(selectedItems, pullImage); + } + ); + }; + + function forceUpdateServices(services, pullImage) { + var actionCount = services.length; + angular.forEach(services, function (service) { + var config = ServiceHelper.serviceToConfig(service.Model); + if (pullImage) { + config.TaskTemplate.ContainerSpec.Image = ImageHelper.removeDigestFromRepository(config.TaskTemplate.ContainerSpec.Image); + } + + // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random + // value or an increment of the counter value to force an update. + config.TaskTemplate.ForceUpdate++; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully updated', service.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to force update service', service.Name); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function removeServices(services) { + var actionCount = services.length; + angular.forEach(services, function (service) { + ServiceService.remove(service) + .then(function success() { + Notifications.success('Service successfully removed', service.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } +}]); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 12794918d..4a63def5c 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
@@ -11,21 +11,12 @@
-
-
- - -
- -
+
{{ item.NodeId | tasknodename: $ctrl.nodes }} {{ item.Updated | getisodate }} - - View logs - - - Console - + View logs + View logs + Console
@@ -65,7 +65,7 @@
- +
diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 1f9353a77..f877c5836 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', -function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper) { +.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', 'LocalStorage', +function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper, LocalStorage) { var socket, term; $scope.state = { @@ -36,7 +36,8 @@ function ($scope, $transition$, ContainerService, ImageService, EndpointProvider ContainerService.createExec(execConfig) .then(function success(data) { execId = data.Id; - var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); + var jwtToken = LocalStorage.getJWT(); + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID() + '&token=' + jwtToken; if ($transition$.params().nodeName) { url += '&nodeName=' + $transition$.params().nodeName; } diff --git a/app/docker/views/containers/console/containerconsole.html b/app/docker/views/containers/console/containerconsole.html index 9432c3a6f..f8b4e7e54 100644 --- a/app/docker/views/containers/console/containerconsole.html +++ b/app/docker/views/containers/console/containerconsole.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Console @@ -8,7 +8,7 @@
- +
diff --git a/app/docker/views/containers/containers.html b/app/docker/views/containers/containers.html index f04727b6d..07f898487 100644 --- a/app/docker/views/containers/containers.html +++ b/app/docker/views/containers/containers.html @@ -1,5 +1,5 @@ - + @@ -10,20 +10,12 @@
diff --git a/app/docker/views/containers/containersController.js b/app/docker/views/containers/containersController.js index fe7fb6c79..9fa20b795 100644 --- a/app/docker/views/containers/containersController.js +++ b/app/docker/views/containers/containersController.js @@ -1,135 +1,11 @@ angular.module('portainer.docker') - .controller('ContainersController', ['$q', '$scope', '$state', '$filter', '$transition$', 'ContainerService', 'SystemService', 'Notifications', 'ModalService', 'EndpointProvider', 'HttpRequestHelper', - function ($q, $scope, $state, $filter, $transition$, ContainerService, SystemService, Notifications, ModalService, EndpointProvider, HttpRequestHelper) { - $scope.state = { - publicURL: EndpointProvider.endpointPublicURL() - }; - - $scope.startAction = function(selectedItems) { - var successMessage = 'Container successfully started'; - var errorMessage = 'Unable to start container'; - executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); - }; - - $scope.stopAction = function(selectedItems) { - var successMessage = 'Container successfully stopped'; - var errorMessage = 'Unable to stop container'; - executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); - }; - - $scope.restartAction = function(selectedItems) { - var successMessage = 'Container successfully restarted'; - var errorMessage = 'Unable to restart container'; - executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); - }; - - $scope.killAction = function(selectedItems) { - var successMessage = 'Container successfully killed'; - var errorMessage = 'Unable to kill container'; - executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); - }; - - $scope.pauseAction = function(selectedItems) { - var successMessage = 'Container successfully paused'; - var errorMessage = 'Unable to pause container'; - executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); - }; - - $scope.resumeAction = function(selectedItems) { - var successMessage = 'Container successfully resumed'; - var errorMessage = 'Unable to resume container'; - executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); - }; - - $scope.confirmRemoveAction = function(selectedItems) { - var isOneContainerRunning = false; - for (var i = 0; i < selectedItems.length; i++) { - var container = selectedItems[i]; - if (container.State === 'running') { - isOneContainerRunning = true; - break; - } - } - - var title = 'You are about to remove one or more container.'; - if (isOneContainerRunning) { - title = 'You are about to remove one or more running container.'; - } - - ModalService.confirmContainerDeletion(title, function (result) { - if(!result) { return; } - var cleanVolumes = false; - if (result[0]) { - cleanVolumes = true; - } - removeAction(selectedItems, cleanVolumes); - } - ); - }; - - function executeActionOnContainerList(containers, action, successMessage, errorMessage) { - var actionCount = containers.length; - angular.forEach(containers, function (container) { - HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); - action(container.Id) - .then(function success() { - Notifications.success(successMessage, container.Names[0]); - }) - .catch(function error(err) { - Notifications.error('Failure', err, errorMessage); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.transitionTo($state.current, { selectedContainers: containers }, { reload: true }); - } - }); - }); - } - - function removeAction(containers, cleanVolumes) { - var actionCount = containers.length; - angular.forEach(containers, function (container) { - HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); - ContainerService.remove(container, cleanVolumes) - .then(function success() { - Notifications.success('Container successfully removed', container.Names[0]); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove container'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - } - - function assignContainers(containers) { - var previouslySelectedContainers = $transition$.params().selectedContainers || []; - $scope.containers = containers.map(function (container) { - container.Status = $filter('containerstatus')(container.Status); - - var previousContainer = _.find(previouslySelectedContainers, function(item) { - return item.Id === container.Id; - }); - - if (previousContainer && previousContainer.Checked) { - container.Checked = true; - } - - return container; - }); - } +.controller('ContainersController', ['$scope', 'ContainerService', 'Notifications', +function ($scope, ContainerService, Notifications) { function initView() { - var provider = $scope.applicationState.endpoint.mode.provider; - ContainerService.containers(1) .then(function success(data) { - assignContainers(data); + $scope.containers = data; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve containers'); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index cca719cf0..d45b989be 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -19,6 +19,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai NodeName: null }; + $scope.extraNetworks = {}; + $scope.state = { formValidationError: '', actionInProgress: false @@ -317,7 +319,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai var bindings = []; for (var p in $scope.config.HostConfig.PortBindings) { if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { - var hostPort = ''; + var hostPort = ''; if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; } @@ -387,7 +389,16 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; // Mac Address - $scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress; + if(Object.keys(d.NetworkSettings.Networks).length) { + var firstNetwork = d.NetworkSettings.Networks[Object.keys(d.NetworkSettings.Networks)[0]]; + $scope.formValues.MacAddress = firstNetwork.MacAddress; + $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = firstNetwork; + $scope.extraNetworks = angular.copy(d.NetworkSettings.Networks); + delete $scope.extraNetworks[Object.keys(d.NetworkSettings.Networks)[0]]; + } else { + $scope.formValues.MacAddress = ''; + } + // ExtraHosts if ($scope.config.HostConfig.ExtraHosts) { var extraHosts = $scope.config.HostConfig.ExtraHosts; @@ -604,14 +615,24 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai }; function createContainer(config, accessControlData) { + var containerIdentifier; $q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true)) .finally(function final() { ContainerService.createAndStartContainer(config) .then(function success(data) { - var containerIdentifier = data.Id; + containerIdentifier = data.Id; var userId = Authentication.getUserDetails().ID; return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []); }) + .then(function success() { + if($scope.extraNetworks) { + return $q.all( + Object.keys($scope.extraNetworks).map(function(networkName) { + return NetworkService.connectContainer(networkName, containerIdentifier); + }) + ); + } + }) .then(function success() { Notifications.success('Container successfully created'); $state.go('docker.containers', {}, {reload: true}); diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index fefcfa7b7..a4c753088 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -1,5 +1,5 @@ - + Containers > Add container @@ -126,11 +126,6 @@ Deploy the container Deployment in progress... - {{ state.formValidationError }} - - - This container is connected to multiple networks, only one network will be kept at creation time. -
@@ -143,7 +138,7 @@
- +
@@ -110,7 +110,7 @@
- +
@@ -139,7 +139,7 @@
- + @@ -178,7 +178,7 @@
- +
@@ -247,7 +247,7 @@
- +
@@ -272,7 +272,7 @@
- + Containers > {{ containerInfo.Name|trimcontainername }} > Inspect @@ -9,7 +9,7 @@
- + diff --git a/app/docker/views/containers/logs/containerlogs.html b/app/docker/views/containers/logs/containerlogs.html index a569f203a..804731ec5 100644 --- a/app/docker/views/containers/logs/containerlogs.html +++ b/app/docker/views/containers/logs/containerlogs.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Logs diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html index f0a974956..99ccf06a4 100644 --- a/app/docker/views/containers/stats/containerstats.html +++ b/app/docker/views/containers/stats/containerstats.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Stats @@ -8,7 +8,7 @@
- + @@ -52,7 +52,7 @@
- +
@@ -62,7 +62,7 @@
- +
@@ -72,7 +72,7 @@
- +
@@ -83,7 +83,7 @@
- + Dashboard @@ -10,9 +10,32 @@
+
+ + +
+ Information +
+
+ +

+ + Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look + at our agent setup for more details. +

+

+ + Portainer is connected to a worker node. Swarm management features will not be available. +

+
+
+
+
+
+
- +
@@ -32,29 +55,11 @@ - -
Memory {{ infoData.MemTotal|humansize }}
-
-
- -
- - - - - - - - - + - - - - - +
This node is part of a Swarm cluster
Node role {{ infoData.Swarm.ControlAvailable ? 'Manager' : 'Worker' }}
Nodes in the cluster{{ infoData.Swarm.Nodes }}
Go to cluster visualizer @@ -69,8 +74,8 @@
-
- +
+
diff --git a/app/docker/views/dashboard/dashboardController.js b/app/docker/views/dashboard/dashboardController.js index 41fabe454..4f478bc60 100644 --- a/app/docker/views/dashboard/dashboardController.js +++ b/app/docker/views/dashboard/dashboardController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.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) { +.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications', 'EndpointProvider', +function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications, EndpointProvider) { $scope.containerData = { total: 0 @@ -65,8 +65,8 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System } function initView() { - var endpointProvider = $scope.applicationState.endpoint.mode.provider; - var endpointRole = $scope.applicationState.endpoint.mode.role; + var endpointMode = $scope.applicationState.endpoint.mode; + var endpointId = EndpointProvider.endpointID(); $q.all([ Container.query({all: 1}).$promise, @@ -74,8 +74,12 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System Volume.query({}).$promise, Network.query({}).$promise, SystemService.info(), - endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [], - endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? StackService.stacks(true) : [] + endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' ? ServiceService.services() : [], + StackService.stacks( + true, + endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', + endpointId + ) ]).then(function (d) { prepareContainerData(d[0]); prepareImageData(d[1]); diff --git a/app/docker/views/engine/engine.html b/app/docker/views/engine/engine.html index cecdf8f51..73b1248b2 100644 --- a/app/docker/views/engine/engine.html +++ b/app/docker/views/engine/engine.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -52,7 +52,7 @@
- +
@@ -94,7 +94,7 @@
- +
diff --git a/app/docker/views/events/events.html b/app/docker/views/events/events.html index 96613262f..87ebb9af8 100644 --- a/app/docker/views/events/events.html +++ b/app/docker/views/events/events.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Images > Build image diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index 1cc50a61b..352784788 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -1,5 +1,5 @@ - + Images > {{ image.Id }} @@ -8,7 +8,7 @@
- +
@@ -59,7 +59,7 @@
- + @@ -88,7 +88,7 @@
- +
@@ -129,7 +129,7 @@
- +
@@ -178,7 +178,7 @@
- +
diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 1b6c807c3..9150a3a05 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -53,7 +53,7 @@
- + Networks > Add network diff --git a/app/docker/views/networks/edit/network.html b/app/docker/views/networks/edit/network.html index b9e269187..08db62c3f 100644 --- a/app/docker/views/networks/edit/network.html +++ b/app/docker/views/networks/edit/network.html @@ -1,5 +1,5 @@ - + Networks > {{ network.Name }} @@ -8,7 +8,7 @@
- +
@@ -58,7 +58,7 @@
- +
@@ -77,7 +77,7 @@
- +
diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index 1c224f0da..9137d518d 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -16,7 +16,7 @@
- +

It looks like the node you wish to inspect does not exist.

@@ -27,7 +27,7 @@
- +
@@ -87,7 +87,7 @@
- +
@@ -116,7 +116,7 @@
- +
@@ -146,7 +146,7 @@
- +

There are no engine labels for this node.

@@ -173,7 +173,7 @@
- +
label @@ -234,7 +234,7 @@
- + Secrets > Add secret diff --git a/app/docker/views/secrets/edit/secret.html b/app/docker/views/secrets/edit/secret.html index 0489137e9..c8c5fa3de 100644 --- a/app/docker/views/secrets/edit/secret.html +++ b/app/docker/views/secrets/edit/secret.html @@ -1,5 +1,5 @@ - + @@ -12,7 +12,7 @@
- +
diff --git a/app/docker/views/secrets/secrets.html b/app/docker/views/secrets/secrets.html index e8d5fc3e0..19f5bfb3c 100644 --- a/app/docker/views/secrets/secrets.html +++ b/app/docker/views/secrets/secrets.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
= 1.25 ? SecretService.secrets() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [], nodes: NodeService.nodes(), diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 6cebf0f3c..11128ea49 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -1,5 +1,5 @@ - + Services > Add service diff --git a/app/docker/views/services/edit/includes/configs.html b/app/docker/views/services/edit/includes/configs.html index 0a8c7cc0e..db36d0edb 100644 --- a/app/docker/views/services/edit/includes/configs.html +++ b/app/docker/views/services/edit/includes/configs.html @@ -1,6 +1,6 @@ - +
diff --git a/app/docker/views/services/edit/includes/constraints.html b/app/docker/views/services/edit/includes/constraints.html index 3817f939d..496ea1492 100644 --- a/app/docker/views/services/edit/includes/constraints.html +++ b/app/docker/views/services/edit/includes/constraints.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/includes/containerlabels.html b/app/docker/views/services/edit/includes/containerlabels.html index 78607b23d..5e8290a27 100644 --- a/app/docker/views/services/edit/includes/containerlabels.html +++ b/app/docker/views/services/edit/includes/containerlabels.html @@ -1,6 +1,6 @@
- +
container label diff --git a/app/docker/views/services/edit/includes/environmentvariables.html b/app/docker/views/services/edit/includes/environmentvariables.html index 4b594a8fb..a1a5d415e 100644 --- a/app/docker/views/services/edit/includes/environmentvariables.html +++ b/app/docker/views/services/edit/includes/environmentvariables.html @@ -1,6 +1,6 @@
- +
environment variable diff --git a/app/docker/views/services/edit/includes/hosts.html b/app/docker/views/services/edit/includes/hosts.html index 7d9ab78c7..a425f2282 100644 --- a/app/docker/views/services/edit/includes/hosts.html +++ b/app/docker/views/services/edit/includes/hosts.html @@ -1,6 +1,6 @@
- +
add host entry diff --git a/app/docker/views/services/edit/includes/logging.html b/app/docker/views/services/edit/includes/logging.html index 2172ac902..b7eb2b7e8 100644 --- a/app/docker/views/services/edit/includes/logging.html +++ b/app/docker/views/services/edit/includes/logging.html @@ -1,6 +1,6 @@
- +
diff --git a/app/docker/views/services/edit/includes/mounts.html b/app/docker/views/services/edit/includes/mounts.html index e8a7b181c..9a88e86e7 100644 --- a/app/docker/views/services/edit/includes/mounts.html +++ b/app/docker/views/services/edit/includes/mounts.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/includes/restart.html b/app/docker/views/services/edit/includes/restart.html index e42de0173..450791c7d 100644 --- a/app/docker/views/services/edit/includes/restart.html +++ b/app/docker/views/services/edit/includes/restart.html @@ -1,6 +1,6 @@
- +
diff --git a/app/docker/views/services/edit/includes/secrets.html b/app/docker/views/services/edit/includes/secrets.html index 57b21aa3c..04444aa6d 100644 --- a/app/docker/views/services/edit/includes/secrets.html +++ b/app/docker/views/services/edit/includes/secrets.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index 6669f53a4..e0b2b49de 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@
- +
@@ -109,7 +109,7 @@
- +
@@ -47,7 +47,7 @@
- + @@ -12,7 +12,7 @@
- +
@@ -77,7 +77,7 @@
- +
diff --git a/app/docker/views/tasks/edit/task.html b/app/docker/views/tasks/edit/task.html index e61bc62dd..b7d5f8cda 100644 --- a/app/docker/views/tasks/edit/task.html +++ b/app/docker/views/tasks/edit/task.html @@ -1,5 +1,5 @@ - + Services > {{ service.Name }} > {{ task.Id }} @@ -8,7 +8,7 @@
- +
@@ -44,7 +44,7 @@ - + diff --git a/app/docker/views/tasks/logs/tasklogs.html b/app/docker/views/tasks/logs/tasklogs.html index 6c5a15f41..abfaa6d45 100644 --- a/app/docker/views/tasks/logs/tasklogs.html +++ b/app/docker/views/tasks/logs/tasklogs.html @@ -1,5 +1,5 @@ - + Services > {{ service.Name }} > {{ task.Id }} > Logs diff --git a/app/docker/views/templates/templates.html b/app/docker/views/templates/templates.html index 5dcec4d26..3c029d719 100644 --- a/app/docker/views/templates/templates.html +++ b/app/docker/views/templates/templates.html @@ -1,5 +1,5 @@ - + @@ -11,7 +11,7 @@
- +
@@ -81,7 +81,7 @@
- +
@@ -350,7 +350,7 @@
- +
Category
Container ID {{ task.Status.ContainerStatus.ContainerID }}
Task logs
@@ -57,7 +57,7 @@
- +
@@ -74,7 +74,7 @@
- +
diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index b31614b21..309dd6686 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html index 973b517d7..ca9fd327b 100644 --- a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html +++ b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html index 642f69a03..24497f375 100644 --- a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html +++ b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/views/cluster/cluster.html b/app/extensions/storidge/views/cluster/cluster.html index 9f40e7035..86bfb95af 100644 --- a/app/extensions/storidge/views/cluster/cluster.html +++ b/app/extensions/storidge/views/cluster/cluster.html @@ -1,5 +1,5 @@ - + @@ -12,7 +12,7 @@
- +
@@ -55,7 +55,7 @@
@@ -65,7 +65,7 @@ - -
-
- - - add label - -
- -
-
-
- name - -
-
- value - -
- -
-
- +
+ Metadata
- + +
+ +
+
diff --git a/app/portainer/components/header-title.js b/app/portainer/components/header-title.js index 92a6e3d4c..c79cd1f2d 100644 --- a/app/portainer/components/header-title.js +++ b/app/portainer/components/header-title.js @@ -3,14 +3,13 @@ angular.module('portainer.app') var directive = { requires: '^rdHeader', scope: { - title: '@' + titleText: '@' }, link: function (scope, iElement, iAttrs) { scope.username = Authentication.getUserDetails().username; - scope.displayDonationHeader = StateManager.getState().application.displayDonationHeader; }, transclude: true, - template: '
{{title}} {{username}} Help support portainer
', + template: '
{{titleText}} {{username}} Portainer support
', restrict: 'E' }; return directive; diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js new file mode 100644 index 000000000..32f5ec116 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('sidebarEndpointSelector', { + templateUrl: 'app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html', + controller: 'SidebarEndpointSelectorController', + bindings: { + 'endpoints': '<', + 'groups': '<', + 'selectEndpoint': '<' + } +}); diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html new file mode 100644 index 000000000..79332e2b0 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html @@ -0,0 +1,27 @@ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js new file mode 100644 index 000000000..ff8d54a57 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app') +.controller('SidebarEndpointSelectorController', function () { + var ctrl = this; + + this.state = { + show: false, + selectedGroup: null, + selectedEndpoint: null + }; + + this.selectGroup = function() { + this.availableEndpoints = this.endpoints.filter(function f(endpoint) { + return endpoint.GroupId === ctrl.state.selectedGroup.Id; + }); + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + this.availableEndpoints = this.endpoints; + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/components/tag-selector/tag-selector.js b/app/portainer/components/tag-selector/tag-selector.js new file mode 100644 index 000000000..46b397d41 --- /dev/null +++ b/app/portainer/components/tag-selector/tag-selector.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('tagSelector', { + templateUrl: 'app/portainer/components/tag-selector/tagSelector.html', + controller: 'TagSelectorController', + bindings: { + tags: '<', + model: '=' + } +}); diff --git a/app/portainer/components/tag-selector/tagSelector.html b/app/portainer/components/tag-selector/tagSelector.html new file mode 100644 index 000000000..be92e1aa4 --- /dev/null +++ b/app/portainer/components/tag-selector/tagSelector.html @@ -0,0 +1,38 @@ +
+ +
+ + {{ tag }} + + + + +
+
+
+ +
+ +
+
+ + No tags available. + +
+
+
+ + No tags matching your filter. + +
diff --git a/app/portainer/components/tag-selector/tagSelectorController.js b/app/portainer/components/tag-selector/tagSelectorController.js new file mode 100644 index 000000000..b7c18c2ca --- /dev/null +++ b/app/portainer/components/tag-selector/tagSelectorController.js @@ -0,0 +1,32 @@ +angular.module('portainer.app') +.controller('TagSelectorController', function () { + + var ctrl = this; + + this.$onChanges = function(changes) { + if(angular.isDefined(changes.tags.currentValue)) { + this.tags = _.difference(changes.tags.currentValue, this.model); + } + }; + + this.state = { + selectedValue: '', + noResult: false + }; + + this.selectTag = function($item, $model, $label) { + this.state.selectedValue = ''; + this.model.push($item); + this.tags = _.remove(this.tags, function(item) { + return item !== $item; + }); + }; + + this.removeTag = function(tag) { + var idx = this.model.indexOf(tag); + if (idx > -1) { + this.model.splice(idx, 1); + this.tags.push(tag); + } + }; +}); diff --git a/app/portainer/components/widget-custom-header.js b/app/portainer/components/widget-custom-header.js index ab4aa8827..8b309a26e 100644 --- a/app/portainer/components/widget-custom-header.js +++ b/app/portainer/components/widget-custom-header.js @@ -3,11 +3,11 @@ angular.module('portainer.app') var directive = { requires: '^rdWidget', scope: { - title: '=', + titleText: '=', icon: '=' }, transclude: true, - template: '
{{title}}
', + template: '
{{titleText}}
', restrict: 'E' }; return directive; diff --git a/app/portainer/components/widget-header.js b/app/portainer/components/widget-header.js index 0755341dc..eea0ae760 100644 --- a/app/portainer/components/widget-header.js +++ b/app/portainer/components/widget-header.js @@ -3,12 +3,12 @@ angular.module('portainer.app') var directive = { requires: '^rdWidget', scope: { - title: '@', + titleText: '@', icon: '@', classes: '@?' }, transclude: true, - template: '
{{title}}
', + template: '
{{titleText}}
', restrict: 'E' }; return directive; diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js index a72ab82cc..bda52fe63 100644 --- a/app/portainer/filters/filters.js +++ b/app/portainer/filters/filters.js @@ -102,6 +102,28 @@ angular.module('portainer.app') return ''; }; }) +.filter('endpointtypename', function () { + 'use strict'; + return function (type) { + if (type === 1) { + return 'Docker'; + } else if (type === 2) { + return 'Agent'; + } else if (type === 3) { + return 'Azure ACI'; + } + return ''; + }; +}) +.filter('endpointtypeicon', function () { + 'use strict'; + return function (type) { + if (type === 3) { + return 'fab fa-microsoft'; + } + return 'fab fa-docker'; + }; +}) .filter('ownershipicon', function () { 'use strict'; return function (ownership) { diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js index 439efeb22..0469a882a 100644 --- a/app/portainer/helpers/stackHelper.js +++ b/app/portainer/helpers/stackHelper.js @@ -3,6 +3,19 @@ angular.module('portainer.app') 'use strict'; var helper = {}; + helper.getExternalStackNamesFromContainers = function(containers) { + var stackNames = []; + + for (var i = 0; i < containers.length; i++) { + var container = containers[i]; + if (!container.Labels || !container.Labels['com.docker.compose.project']) continue; + var stackName = container.Labels['com.docker.compose.project']; + stackNames.push(stackName); + } + + return _.uniq(stackNames); + }; + helper.getExternalStackNamesFromServices = function(services) { var stackNames = []; @@ -16,6 +29,6 @@ angular.module('portainer.app') return _.uniq(stackNames); }; - + return helper; }]); diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js index bb8cf0b78..b16d825f7 100644 --- a/app/portainer/models/group.js +++ b/app/portainer/models/group.js @@ -1,14 +1,14 @@ function EndpointGroupDefaultModel() { this.Name = ''; this.Description = ''; - this.Labels = []; + this.Tags = []; } function EndpointGroupModel(data) { this.Id = data.Id; this.Name = data.Name; this.Description = data.Description; - this.Labels = data.Labels; + this.Tags = data.Tags; this.AuthorizedUsers = data.AuthorizedUsers; this.AuthorizedTeams = data.AuthorizedTeams; } @@ -16,7 +16,7 @@ function EndpointGroupModel(data) { function EndpointGroupCreateRequest(model, endpoints) { this.Name = model.Name; this.Description = model.Description; - this.Labels = model.Labels; + this.Tags = model.Tags; this.AssociatedEndpoints = endpoints; } @@ -24,6 +24,6 @@ function EndpointGroupUpdateRequest(model, endpoints) { this.id = model.Id; this.Name = model.Name; this.Description = model.Description; - this.Labels = model.Labels; + this.Tags = model.Tags; this.AssociatedEndpoints = endpoints; } diff --git a/app/portainer/models/settings/settings.js b/app/portainer/models/settings/settings.js index 300de0733..4c130b052 100644 --- a/app/portainer/models/settings/settings.js +++ b/app/portainer/models/settings/settings.js @@ -2,7 +2,6 @@ function SettingsViewModel(data) { this.TemplatesURL = data.TemplatesURL; this.LogoURL = data.LogoURL; this.BlackListedLabels = data.BlackListedLabels; - this.DisplayDonationHeader = data.DisplayDonationHeader; this.DisplayExternalContributors = data.DisplayExternalContributors; this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; diff --git a/app/docker/models/stack.js b/app/portainer/models/stack.js similarity index 53% rename from app/docker/models/stack.js rename to app/portainer/models/stack.js index 7d1310246..813027f97 100644 --- a/app/docker/models/stack.js +++ b/app/portainer/models/stack.js @@ -1,10 +1,20 @@ function StackViewModel(data) { this.Id = data.Id; + this.Type = data.Type; this.Name = data.Name; this.Checked = false; + this.EndpointId = data.EndpointId; + this.SwarmId = data.SwarmId; this.Env = data.Env ? data.Env : []; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } - this.External = data.External; + this.External = false; +} + +function ExternalStackViewModel(name, type) { + this.Name = name; + this.Type = type; + this.External = true; + this.Checked = false; } diff --git a/app/portainer/models/tag.js b/app/portainer/models/tag.js new file mode 100644 index 000000000..dfefaff61 --- /dev/null +++ b/app/portainer/models/tag.js @@ -0,0 +1,4 @@ +function TagViewModel(data) { + this.Id = data.ID; + this.Name = data.Name; +} diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 1ebda7274..6d8807791 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -1,15 +1,14 @@ angular.module('portainer.app') -.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { +.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_STACKS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_STACKS) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/stacks/:id/:action', { - endpointId: EndpointProvider.endpointID - }, + return $resource(API_ENDPOINT_STACKS + '/:id/:action', {}, { get: { method: 'GET', params: { id: '@id' } }, query: { method: 'GET', isArray: true }, create: { method: 'POST', ignoreLoadingBar: true }, update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, - remove: { method: 'DELETE', params: { id: '@id'} }, - getStackFile: { method: 'GET', params: { id : '@id', action: 'stackfile' } } + remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, + getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } }, + migrate: { method: 'POST', params: { id : '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true } }); }]); diff --git a/app/portainer/rest/tag.js b/app/portainer/rest/tag.js new file mode 100644 index 000000000..e3e656036 --- /dev/null +++ b/app/portainer/rest/tag.js @@ -0,0 +1,9 @@ +angular.module('portainer.app') +.factory('Tags', ['$resource', 'API_ENDPOINT_TAGS', function TagsFactory($resource, API_ENDPOINT_TAGS) { + 'use strict'; + return $resource(API_ENDPOINT_TAGS + '/:id', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 85e15aa4f..e7dd9bb97 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -33,25 +33,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; - service.updateEndpoint = function(id, endpointParams) { - var query = { - name: endpointParams.name, - PublicURL: endpointParams.PublicURL, - GroupId: endpointParams.GroupId, - TLS: endpointParams.TLS, - TLSSkipVerify: endpointParams.TLSSkipVerify, - TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, - authorizedUsers: endpointParams.authorizedUsers - }; - if (endpointParams.type && endpointParams.URL) { - query.URL = endpointParams.type === 'local' ? ('unix://' + endpointParams.URL) : ('tcp://' + endpointParams.URL); - } - + service.updateEndpoint = function(id, payload) { var deferred = $q.defer(); - FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCACert, endpointParams.TLSCert, endpointParams.TLSKey) + FileUploadService.uploadTLSFilesForEndpoint(id, payload.TLSCACert, payload.TLSCert, payload.TLSKey) .then(function success() { deferred.notify({upload: false}); - return Endpoints.update({id: id}, query).$promise; + return Endpoints.update({id: id}, payload).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -70,7 +57,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { service.createLocalEndpoint = function() { var deferred = $q.defer(); - FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false) + FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, [], false) .then(function success(response) { deferred.resolve(response.data); }) @@ -81,10 +68,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var deferred = $q.defer(); - FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(response) { deferred.resolve(response.data); }) @@ -95,5 +82,19 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) { + var deferred = $q.defer(); + + FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to connect to Azure', err: err}); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js new file mode 100644 index 000000000..c32f6bec2 --- /dev/null +++ b/app/portainer/services/api/stackService.js @@ -0,0 +1,321 @@ +angular.module('portainer.app') +.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', 'EndpointProvider', +function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService, EndpointProvider) { + '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.migrateSwarmStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + if (swarm.Id === stack.SwarmId) { + deferred.reject({ msg: 'Target endpoint is located in the same Swarm cluster as the current endpoint', err: null }); + return; + } + + return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + }) + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }) + .finally(function final() { + EndpointProvider.setEndpointID(stack.EndpointId); + }); + + return deferred.promise; + }; + + service.migrateComposeStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId }).$promise + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + EndpointProvider.setEndpointID(stack.EndpointId); + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }); + + return deferred.promise; + }; + + service.stacks = function(compose, swarm, endpointId) { + var deferred = $q.defer(); + + var queries = []; + if (compose) { + queries.push(service.composeStacks(true, { EndpointID: endpointId })); + } + if (swarm) { + queries.push(service.swarmStacks(true)); + } + + $q.all(queries) + .then(function success(data) { + var stacks = []; + if (data[0]) { + stacks = stacks.concat(data[0]); + } + if (data[1]) { + stacks = stacks.concat(data[1]); + } + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalSwarmStacks = function() { + var deferred = $q.defer(); + + ServiceService.services() + .then(function success(data) { + var services = data; + var stackNames = StackHelper.getExternalStackNamesFromServices(services); + var stacks = stackNames.map(function (name) { + return new ExternalStackViewModel(name, 1); + }); + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalComposeStacks = function() { + var deferred = $q.defer(); + + ContainerService.containers(1) + .then(function success(data) { + var containers = data; + var stackNames = StackHelper.getExternalStackNamesFromContainers(containers); + var stacks = stackNames.map(function (name) { + return new ExternalStackViewModel(name, 2); + }); + deferred.resolve(stacks); + + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.composeStacks = function(includeExternalStacks, filters) { + var deferred = $q.defer(); + + $q.all({ + stacks: Stack.query({filters: filters}).$promise, + externalStacks: includeExternalStacks ? service.externalComposeStacks() : [] + }) + .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.swarmStacks = function(includeExternalStacks) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var filters = { SwarmID: swarm.Id }; + + return $q.all({ + stacks: Stack.query({ filters: filters }).$promise, + externalStacks: includeExternalStacks ? service.externalSwarmStacks() : [] + }); + }) + .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, external, endpointId) { + var deferred = $q.defer(); + + Stack.remove({ id: stack.Id ? stack.Id : stack.Name, external: external, endpointId: endpointId }).$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.updateStack = function(stack, stackFile, env, prune) { + return Stack.update({ endpointId: stack.EndpointId }, { id: stack.Id, StackFileContent: stackFile, Env: env, Prune: prune }).$promise; + }; + + service.createComposeStackFromFileUpload = function(name, stackFile, endpointId) { + return FileUploadService.createComposeStack(name, stackFile, endpointId); + }; + + service.createSwarmStackFromFileUpload = function(name, stackFile, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + return FileUploadService.createSwarmStack(name, swarm.Id, stackFile, env, endpointId); + }) + .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.createComposeStackFromFileContent = function(name, stackFileContent, endpointId) { + var payload = { + Name: name, + StackFileContent: stackFileContent + }; + return Stack.create({ method: 'string', type: 2, endpointId: endpointId }, payload).$promise; + }; + + service.createSwarmStackFromFileContent = function(name, stackFileContent, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var payload = { + Name: name, + SwarmID: swarm.Id, + StackFileContent: stackFileContent, + Env: env + }; + return Stack.create({ method: 'string', type: 1, endpointId: endpointId }, payload).$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.createComposeStackFromGitRepository = function(name, repositoryOptions, endpointId) { + var payload = { + Name: name, + RepositoryURL: repositoryOptions.RepositoryURL, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword + }; + return Stack.create({ method: 'repository', type: 2, endpointId: endpointId }, payload).$promise; + }; + + service.createSwarmStackFromGitRepository = function(name, repositoryOptions, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var payload = { + Name: name, + SwarmID: swarm.Id, + RepositoryURL: repositoryOptions.RepositoryURL, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + Env: env + }; + return Stack.create({ method: 'repository', type: 1, endpointId: endpointId }, payload).$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; + }; + + return service; +}]); diff --git a/app/portainer/services/api/tagService.js b/app/portainer/services/api/tagService.js new file mode 100644 index 000000000..a73b9f932 --- /dev/null +++ b/app/portainer/services/api/tagService.js @@ -0,0 +1,49 @@ +angular.module('portainer.app') +.factory('TagService', ['$q', 'Tags', function TagServiceFactory($q, Tags) { + 'use strict'; + var service = {}; + + service.tags = function() { + var deferred = $q.defer(); + Tags.query().$promise + .then(function success(data) { + var tags = data.map(function (item) { + return new TagViewModel(item); + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve tags', err: err}); + }); + return deferred.promise; + }; + + service.tagNames = function() { + var deferred = $q.defer(); + Tags.query().$promise + .then(function success(data) { + var tags = data.map(function (item) { + return item.Name; + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve tags', err: err}); + }); + return deferred.promise; + }; + + service.createTag = function(name) { + var payload = { + Name: name + }; + + return Tags.create({}, payload).$promise; + }; + + service.deleteTag = function(id) { + return Tags.remove({id: id}).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/datatableService.js b/app/portainer/services/datatableService.js index 67f723349..7809c4b1a 100644 --- a/app/portainer/services/datatableService.js +++ b/app/portainer/services/datatableService.js @@ -33,5 +33,21 @@ function DatatableServiceFactory(LocalStorage) { LocalStorage.storeDataTableOrder(key, filter); }; + service.setDataTableExpandedItems = function(key, expandedItems) { + LocalStorage.storeDataTableExpandedItems(key, expandedItems); + }; + + service.getDataTableExpandedItems = function(key) { + return LocalStorage.getDataTableExpandedItems(key); + }; + + service.setDataTableSelectedItems = function(key, selectedItems) { + LocalStorage.storeDataTableSelectedItems(key, selectedItems); + }; + + service.getDataTableSelectedItems = function(key) { + return LocalStorage.getDataTableSelectedItems(key); + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index d2c2120d6..aef41e47a 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -28,10 +28,9 @@ angular.module('portainer.app') }); }; - service.createStack = function(stackName, swarmId, file, env) { - var endpointID = EndpointProvider.endpointID(); + service.createSwarmStack = function(stackName, swarmId, file, env, endpointId) { return Upload.upload({ - url: 'api/endpoints/' + endpointID + '/stacks?method=file', + url: 'api/stacks?method=file&type=1&endpointId=' + endpointId, data: { file: file, Name: stackName, @@ -42,14 +41,27 @@ angular.module('portainer.app') }); }; - service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createComposeStack = function(stackName, file, endpointId) { + return Upload.upload({ + url: 'api/stacks?method=file&type=2&endpointId=' + endpointId, + data: { + file: file, + Name: stackName + }, + ignoreLoadingBar: true + }); + }; + + service.createEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', data: { Name: name, + EndpointType: type, URL: URL, PublicURL: PublicURL, GroupID: groupID, + Tags: Upload.json(tags), TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, @@ -61,6 +73,22 @@ angular.module('portainer.app') }); }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointType: 3, + GroupID: groupID, + Tags: Upload.json(tags), + AzureApplicationID: applicationId, + AzureTenantID: tenantId, + AzureAuthenticationKey: authenticationKey + }, + ignoreLoadingBar: true + }); + }; + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { var queue = []; diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 1487a20ea..c94e0cadc 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -59,6 +59,18 @@ angular.module('portainer.app') storeDataTableSettings: function(key, data) { localStorageService.set('datatable_settings_' + key, data); }, + getDataTableExpandedItems: function(key) { + return localStorageService.get('datatable_expandeditems_' + key); + }, + storeDataTableExpandedItems: function(key, data) { + localStorageService.set('datatable_expandeditems_' + key, data); + }, + getDataTableSelectedItems: function(key) { + return localStorageService.get('datatable_selecteditems_' + key); + }, + storeDataTableSelectedItems: function(key, data) { + localStorageService.set('datatable_selecteditems_' + key, data); + }, storeSwarmVisualizerSettings: function(key, data) { localStorageService.set('swarmvisualizer_' + key, data); }, diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 0edd374f2..196b74640 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -157,9 +157,16 @@ angular.module('portainer.app') }; service.confirmServiceForceUpdate = function(message, callback) { - service.confirm({ + service.customPrompt({ title: 'Are you sure ?', message: message, + inputType: 'checkbox', + inputOptions: [ + { + text: 'Pull latest image version', + value: '1' + } + ], buttons: { confirm: { label: 'Update', diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index c7322a4c3..d27844e60 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -30,18 +30,12 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; - manager.updateDonationHeader = function(displayDonationHeader) { - state.application.displayDonationHeader = displayDonationHeader; - LocalStorage.storeApplicationState(state.application); - }; - function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; state.application.endpointManagement = status.EndpointManagement; state.application.version = status.Version; state.application.logo = settings.LogoURL; - state.application.displayDonationHeader = settings.DisplayDonationHeader; state.application.displayExternalContributors = settings.DisplayExternalContributors; state.application.validity = moment().unix(); } @@ -128,6 +122,14 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin if (loading) { state.loading = true; } + + if (type === 3) { + state.endpoint.mode = { provider: 'AZURE' }; + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + return deferred.promise; + } + $q.all({ version: SystemService.version(), info: SystemService.info() diff --git a/app/portainer/views/about/about.html b/app/portainer/views/about/about.html index 6486ad264..e95dc837f 100644 --- a/app/portainer/views/about/about.html +++ b/app/portainer/views/about/about.html @@ -1,5 +1,5 @@ - + About Portainer @@ -19,20 +19,12 @@
- +

It is a community effort to make Portainer as feature-rich as simple it is to deploy and use. We need all the help we can get!

-

- Fund our work -

-

Contribute

    @@ -56,7 +48,7 @@
    - +

    @@ -89,7 +81,7 @@

    - +
    Portainer has full support for Docker >=1.10 and partial support for Docker 1.9 (some features may not be available). diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html index 745caa91b..588631954 100644 --- a/app/portainer/views/account/account.html +++ b/app/portainer/views/account/account.html @@ -1,12 +1,12 @@ - + User settings
    - +
    diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 7b53dfe91..ab65f2163 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -13,12 +13,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica AuthenticationError: '' }; - function setActiveEndpointAndRedirectToDashboard(endpoint) { - var endpointID = EndpointProvider.endpointID(); - if (!endpointID) { - EndpointProvider.setEndpointID(endpoint.Id); - } - + function redirectToDockerDashboard(endpoint) { ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; @@ -32,12 +27,31 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica }); } + function redirectToAzureDashboard(endpoint) { + StateManager.updateEndpointState(false, endpoint.Type, []) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); + }); + } + + function redirectToDashboard(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + + if (endpoint.Type === 3) { + return redirectToAzureDashboard(endpoint); + } + redirectToDockerDashboard(endpoint); + } + function unauthenticatedFlow() { EndpointService.endpoints() .then(function success(data) { var endpoints = data; if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else { $state.go('portainer.init.endpoint'); } @@ -79,7 +93,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica var endpoints = data; var userDetails = Authentication.getUserDetails(); if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else if (endpoints.length === 0 && userDetails.role === 1) { $state.go('portainer.init.endpoint'); } else if (endpoints.length === 0 && userDetails.role === 2) { diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html index eae43d390..ed60af804 100644 --- a/app/portainer/views/endpoints/access/endpointAccess.html +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -1,5 +1,5 @@ - + Endpoints > {{ endpoint.Name }} > Access management @@ -8,7 +8,7 @@
    - +
diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index bda06746d..6453902e9 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('CreateEndpointController', ['$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'Notifications', -function ($scope, $state, $filter, EndpointService, GroupService, Notifications) { +.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'TagService', 'Notifications', +function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService, Notifications) { $scope.state = { EnvironmentType: 'docker', @@ -12,7 +12,11 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) URL: '', PublicURL: '', GroupId: 1, - SecurityFormData: new EndpointSecurityFormData() + SecurityFormData: new EndpointSecurityFormData(), + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '', + Tags: [] }; $scope.addDockerEndpoint = function() { @@ -20,6 +24,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var URL = $filter('stripprotocol')($scope.formValues.URL); var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; var securityData = $scope.formValues.SecurityFormData; var TLS = securityData.TLS; @@ -30,7 +35,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; - addEndpoint(name, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + addEndpoint(name, 1, URL, publicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; $scope.addAgentEndpoint = function() { @@ -38,13 +43,42 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var URL = $filter('stripprotocol')($scope.formValues.URL); var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; - addEndpoint(name, URL, publicURL, groupId, true, true, true, null, null, null); + addEndpoint(name, 2, URL, publicURL, groupId, tags, true, true, true, null, null, null); }; - function addEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.addAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags); + }; + + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) { + var endpoint; + $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) + .then(function success() { + Notifications.success('Endpoint created', name); + $state.go('portainer.endpoints', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create endpoint'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function addEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.state.actionInProgress = true; + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { Notifications.success('Endpoint created', name); $state.go('portainer.endpoints', {}, {reload: true}); @@ -58,9 +92,13 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) } function initView() { - GroupService.groups() + $q.all({ + groups: GroupService.groups(), + tags: TagService.tagNames() + }) .then(function success(data) { - $scope.groups = data; + $scope.groups = data.groups; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to load groups'); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index 1ab647c4b..cce0f67c7 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -1,5 +1,5 @@ - + Endpoints > Add endpoint @@ -36,6 +36,16 @@

Portainer agent

+
+ + +
@@ -59,6 +69,28 @@
+
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
Environment details
@@ -78,35 +110,94 @@ -
- -
- - +
+
+ +
+ + +
-
-
-
-
-

This field is required.

+
+
+
+

This field is required.

+
-
- -
- +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ + + + +
+ Metadata +
- - - + +
+ +
+ +
+ Actions +
@@ -131,6 +230,10 @@ Add endpoint Creating endpoint... +
diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 54e75b445..6a1349d35 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -1,5 +1,5 @@ - + Endpoints > {{ endpoint.Name }} @@ -28,12 +28,12 @@
- +
-
+
+
- Grouping + Metadata
@@ -56,8 +61,16 @@
+ +
+ +
+ -
+
Security
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index bdcbc63b0..378924ccf 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications', -function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) { +.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'TagService', 'EndpointProvider', 'Notifications', +function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, TagService, EndpointProvider, Notifications) { if (!$scope.applicationState.application.endpointManagement) { $state.go('portainer.endpoints'); @@ -23,22 +23,28 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only'); var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only'); - var endpointParams = { - name: endpoint.Name, - URL: endpoint.URL, + var payload = { + Name: endpoint.Name, PublicURL: endpoint.PublicURL, - GroupId: endpoint.GroupId, + GroupID: endpoint.GroupId, + Tags: endpoint.Tags, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, TLSCACert: TLSSkipVerify || securityData.TLSCACert === endpoint.TLSConfig.TLSCACert ? null : securityData.TLSCACert, TLSCert: TLSSkipClientVerify || securityData.TLSCert === endpoint.TLSConfig.TLSCert ? null : securityData.TLSCert, TLSKey: TLSSkipClientVerify || securityData.TLSKey === endpoint.TLSConfig.TLSKey ? null : securityData.TLSKey, - type: $scope.endpointType + AzureApplicationID: endpoint.AzureCredentials.ApplicationID, + AzureTenantID: endpoint.AzureCredentials.TenantID, + AzureAuthenticationKey: endpoint.AzureCredentials.AuthenticationKey }; + if ($scope.endpointType !== 'local' && endpoint.Type !== 3) { + payload.URL = 'tcp://' + endpoint.URL; + } + $scope.state.actionInProgress = true; - EndpointService.updateEndpoint(endpoint.Id, endpointParams) + EndpointService.updateEndpoint(endpoint.Id, payload) .then(function success(data) { Notifications.success('Endpoint updated', $scope.endpoint.Name); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); @@ -56,7 +62,8 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi function initView() { $q.all({ endpoint: EndpointService.endpoint($transition$.params().id), - groups: GroupService.groups() + groups: GroupService.groups(), + tags: TagService.tagNames() }) .then(function success(data) { var endpoint = data.endpoint; @@ -68,6 +75,7 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi endpoint.URL = $filter('stripprotocol')(endpoint.URL); $scope.endpoint = endpoint; $scope.groups = data.groups; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/endpoints/endpoints.html b/app/portainer/views/endpoints/endpoints.html index 75459a89a..cb37ae53e 100644 --- a/app/portainer/views/endpoints/endpoints.html +++ b/app/portainer/views/endpoints/endpoints.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Portainer has been started using the --external-endpoints flag. @@ -25,7 +25,7 @@
- + Groups > {{ group.Name }} > Access management @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/groups/create/createGroupController.js b/app/portainer/views/groups/create/createGroupController.js index 1a14510fa..9fcf47c84 100644 --- a/app/portainer/views/groups/create/createGroupController.js +++ b/app/portainer/views/groups/create/createGroupController.js @@ -1,19 +1,11 @@ angular.module('portainer.app') -.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications', -function ($scope, $state, GroupService, EndpointService, Notifications) { +.controller('CreateGroupController', ['$q', '$scope', '$state', 'GroupService', 'EndpointService', 'TagService', 'Notifications', +function ($q, $scope, $state, GroupService, EndpointService, TagService, Notifications) { $scope.state = { actionInProgress: false }; - $scope.addLabel = function() { - $scope.model.Labels.push({ name: '', value: '' }); - }; - - $scope.removeLabel = function(index) { - $scope.model.Labels.splice(index, 1); - }; - $scope.create = function() { var model = $scope.model; @@ -40,10 +32,14 @@ function ($scope, $state, GroupService, EndpointService, Notifications) { function initView() { $scope.model = new EndpointGroupDefaultModel(); - EndpointService.endpointsByGroup(1) + $q.all({ + endpoints: EndpointService.endpointsByGroup(1), + tags: TagService.tagNames() + }) .then(function success(data) { - $scope.availableEndpoints = data; + $scope.availableEndpoints = data.endpoints; $scope.associatedEndpoints = []; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoints'); diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html index e4938e6ff..36334979c 100644 --- a/app/portainer/views/groups/create/creategroup.html +++ b/app/portainer/views/groups/create/creategroup.html @@ -1,5 +1,5 @@ - + Endpoint groups > Add group @@ -12,6 +12,7 @@ - + Groups > {{ group.Name }} @@ -12,6 +12,7 @@ - + @@ -10,7 +10,7 @@
-
+
@@ -55,6 +55,16 @@

Connect to a Portainer agent

+
+ + +
@@ -141,6 +151,78 @@
+ +
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
+ Environment +
+ +
+ +
+ +
+
+ +
+ Azure credentials +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 67d9da7b1..a7e8f04bc 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -22,7 +22,10 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif TLSSKipClientVerify: false, TLSCACert: null, TLSCert: null, - TLSKey: null + TLSKey: null, + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '' }; $scope.createLocalEndpoint = function() { @@ -52,12 +55,21 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }); }; + $scope.createAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + }; + $scope.createAgentEndpoint = function() { var name = $scope.formValues.Name; var URL = $scope.formValues.URL; var PublicURL = URL.split(':')[0]; - createRemoteEndpoint(name, URL, PublicURL, true, true, true, null, null, null); + createRemoteEndpoint(name, 2, URL, PublicURL, true, true, true, null, null, null); }; $scope.createRemoteEndpoint = function() { @@ -71,13 +83,34 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; - createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + createRemoteEndpoint(name, 1, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; - function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + var endpoint; + + $scope.state.actionInProgress = true; + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, 1, []) + .then(function success(data) { + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return StateManager.updateEndpointState(false, endpoint.Type, []); + }) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure environment'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function createRemoteEndpoint(name, type, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, [], TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { endpoint = data; EndpointProvider.setEndpointID(endpoint.Id); diff --git a/app/portainer/views/registries/access/registryAccess.html b/app/portainer/views/registries/access/registryAccess.html index 18758a7ab..b5485f4cb 100644 --- a/app/portainer/views/registries/access/registryAccess.html +++ b/app/portainer/views/registries/access/registryAccess.html @@ -1,5 +1,5 @@ - + Registries > {{ registry.Name }} > Access management @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createregistry.html index 632a09d12..e4e1499b0 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createregistry.html @@ -1,5 +1,5 @@ - + Registries > Add registry diff --git a/app/portainer/views/registries/edit/registry.html b/app/portainer/views/registries/edit/registry.html index 9e794548c..c4d8ba9df 100644 --- a/app/portainer/views/registries/edit/registry.html +++ b/app/portainer/views/registries/edit/registry.html @@ -1,5 +1,5 @@ - + Registries > {{ registry.Name }} diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 8c822d8ee..72a7561d3 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -71,7 +71,7 @@
- + Settings > Authentication @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index cc5b0b50a..83195f3e7 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -1,24 +1,14 @@ - + Settings
- + -
-
- - -
-
@@ -133,7 +123,7 @@
- +
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index bc0623bc9..f21d28446 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -9,7 +9,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ $scope.formValues = { customLogo: false, customTemplates: false, - donationHeader: true, externalContributions: false, restrictBindMounts: false, restrictPrivilegedMode: false, @@ -46,7 +45,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ settings.TemplatesURL = DEFAULT_TEMPLATES_URL; } - settings.DisplayDonationHeader = !$scope.formValues.donationHeader; settings.DisplayExternalContributors = !$scope.formValues.externalContributions; settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts; settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; @@ -60,7 +58,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ .then(function success(data) { Notifications.success('Settings updated'); StateManager.updateLogo(settings.LogoURL); - StateManager.updateDonationHeader(settings.DisplayDonationHeader); StateManager.updateExternalContributions(settings.DisplayExternalContributors); $state.reload(); }) @@ -83,7 +80,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ if (settings.TemplatesURL !== DEFAULT_TEMPLATES_URL) { $scope.formValues.customTemplates = true; } - $scope.formValues.donationHeader = !settings.DisplayDonationHeader; $scope.formValues.externalContributions = !settings.DisplayExternalContributors; $scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers; $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index e182e5a01..32c5bed32 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -9,13 +9,15 @@
- + This stack will be deployed using the equivalent of the docker stack deploy command. + + This stack will be deployed using the equivalent of docker-compose. Only Compose file format version 2 is supported at the moment. +
@@ -156,34 +159,36 @@
-
- Environment -
-
-
- - - add environment variable - +
+
+ Environment
- -
-
-
- name - -
-
- value - -
- +
+
+ + + add environment variable +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+
-
diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html new file mode 100644 index 000000000..ca400809c --- /dev/null +++ b/app/portainer/views/stacks/edit/stack.html @@ -0,0 +1,206 @@ + + + + + + + + Stacks > {{ stackName }} + + + +
+
+ + + + + + + Stack + +
+ +
+
+ Information +
+
+ +

+ + This stack was created outside of Portainer. Control over this stack is limited. +

+
+
+
+ + +
+
+ Stack details +
+
+ {{ stackName }} + +
+
+ + +
+
+ Stack migration +
+
+ +

+ This feature allows you to migrate this stack to an alternate compatible endpoint. +

+
+
+ + +
+
+
+ +
+
+ + + + + Editor + + +
+ + You can get more information about Compose file format in the official documentation. + +
+
+
+ +
+
+ +
+
+ Environment +
+
+
+ + + add environment variable + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+
+ + +
+
+ Options +
+
+
+ + +
+
+
+ +
+ Actions +
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js new file mode 100644 index 000000000..57356dbe7 --- /dev/null +++ b/app/portainer/views/stacks/edit/stackController.js @@ -0,0 +1,270 @@ +angular.module('portainer.app') +.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper', 'EndpointProvider', 'EndpointService', 'GroupService', 'ModalService', +function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper, EndpointProvider, EndpointService, GroupService, ModalService) { + + $scope.state = { + actionInProgress: false, + migrationInProgress: false, + externalStack: false, + showEditorTab: false + }; + + $scope.formValues = { + Prune: false, + Endpoint: null + }; + + $scope.showEditor = function() { + $scope.state.showEditorTab = true; + }; + + $scope.migrateStack = function() { + ModalService.confirm({ + title: 'Are you sure?', + message: 'This action will deploy a new instance of this stack on the target endpoint, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.', + buttons: { + confirm: { + label: 'Migrate', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { return; } + migrateStack(); + } + }); + }; + + $scope.removeStack = function() { + ModalService.confirmDeletion( + 'Do you want to remove the stack? Associated services will be removed as well.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteStack(); + } + ); + }; + + function migrateStack() { + var stack = $scope.stack; + var targetEndpointId = $scope.formValues.Endpoint.Id; + + var migrateRequest = StackService.migrateSwarmStack; + if (stack.Type === 2) { + migrateRequest = StackService.migrateComposeStack; + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, we can pass + // the current endpoint identifier as a part of the migrate request. It will be used if + // the EndpointID property is not defined on the stack. + var endpointId = EndpointProvider.endpointID(); + if (stack.EndpointId === 0) { + stack.EndpointId = endpointId; + } + + $scope.state.migrationInProgress = true; + migrateRequest(stack, targetEndpointId) + .then(function success(data) { + Notifications.success('Stack successfully migrated', stack.Name); + $state.go('portainer.stacks', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to migrate stack'); + }) + .finally(function final() { + $scope.state.migrationInProgress = false; + }); + } + + function deleteStack() { + var endpointId = EndpointProvider.endpointID(); + var stack = $scope.stack; + + StackService.remove(stack, $transition$.params().external, endpointId) + .then(function success() { + Notifications.success('Stack successfully removed', stack.Name); + $state.go('portainer.stacks'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); + }); + } + + $scope.deployStack = function () { + var stackFile = $scope.stackFileContent; + var env = FormHelper.removeInvalidEnvVars($scope.stack.Env); + var prune = $scope.formValues.Prune; + var stack = $scope.stack; + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, we can pass + // the current endpoint identifier as a part of the update request. It will be used if + // the EndpointID property is not defined on the stack. + var endpointId = EndpointProvider.endpointID(); + if (stack.EndpointId === 0) { + stack.EndpointId = endpointId; + } + + $scope.state.actionInProgress = true; + StackService.updateStack(stack, stackFile, env, prune) + .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() { + $scope.state.actionInProgress = false; + }); + }; + + $scope.addEnvironmentVariable = function() { + $scope.stack.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.stack.Env.splice(index, 1); + }; + + $scope.editorUpdate = function(cm) { + $scope.stackFileContent = cm.getValue(); + }; + + function loadStack(id) { + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + var endpointId = EndpointProvider.endpointID(); + + $q.all({ + stack: StackService.stack(id), + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + var stack = data.stack; + $scope.endpoints = data.endpoints.filter(function(endpoint) { + return endpoint.Id !== endpointId; + }); + $scope.groups = data.groups; + $scope.stack = stack; + + return $q.all({ + stackFile: StackService.getStackFile(id), + resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name) + }); + }) + .then(function success(data) { + $scope.stackFileContent = data.stackFile; + if ($scope.stack.Type === 1) { + assignSwarmStackResources(data.resources, agentProxy); + } else { + assignComposeStackResources(data.resources); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function retrieveSwarmStackResources(stackName, agentProxy) { + var stackFilter = { + label: ['com.docker.stack.namespace=' + stackName] + }; + + return $q.all({ + services: ServiceService.services(stackFilter), + tasks: TaskService.tasks(stackFilter), + containers: agentProxy ? ContainerService.containers(1) : [], + nodes: NodeService.nodes() + }); + } + + function assignSwarmStackResources(resources, agentProxy) { + var services = resources.services; + var tasks = resources.tasks; + + if (agentProxy) { + var containers = resources.containers; + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + for (var i = 0; i < services.length; i++) { + var service = services[i]; + ServiceHelper.associateTasksToService(service, tasks); + } + + $scope.nodes = resources.nodes; + $scope.tasks = tasks; + $scope.services = services; + } + + function retrieveComposeStackResources(stackName) { + var stackFilter = { + label: ['com.docker.compose.project=' + stackName] + }; + + return $q.all({ + containers: ContainerService.containers(1, stackFilter) + }); + } + + function assignComposeStackResources(resources) { + $scope.containers = resources.containers; + } + + function loadExternalStack(name) { + var stackType = $transition$.params().type; + if (!stackType || (stackType !== '1' && stackType !== '2')) { + Notifications.error('Failure', err, 'Invalid type URL parameter.'); + return; + } + + if (stackType === '1') { + loadExternalSwarmStack(name); + } else { + loadExternalComposeStack(name); + } + } + + function loadExternalSwarmStack(name) { + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + + retrieveSwarmStackResources(name) + .then(function success(data) { + assignSwarmStackResources(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function loadExternalComposeStack(name) { + retrieveComposeStackResources(name) + .then(function success(data) { + assignComposeStackResources(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function initView() { + var stackName = $transition$.params().name; + $scope.stackName = stackName; + var external = $transition$.params().external; + + if (external === 'true') { + $scope.state.externalStack = true; + loadExternalStack(stackName); + } else { + var stackId = $transition$.params().id; + loadStack(stackId); + } + } + + initView(); +}]); diff --git a/app/portainer/views/stacks/stacks.html b/app/portainer/views/stacks/stacks.html new file mode 100644 index 000000000..b1b2c69c5 --- /dev/null +++ b/app/portainer/views/stacks/stacks.html @@ -0,0 +1,20 @@ + + + + + + + Stacks + + +
+
+ +
+
diff --git a/app/docker/views/stacks/stacksController.js b/app/portainer/views/stacks/stacksController.js similarity index 73% rename from app/docker/views/stacks/stacksController.js rename to app/portainer/views/stacks/stacksController.js index e51495dbf..52482ec0c 100644 --- a/app/docker/views/stacks/stacksController.js +++ b/app/portainer/views/stacks/stacksController.js @@ -1,11 +1,6 @@ -angular.module('portainer.docker') -.controller('StacksController', ['$scope', '$state', 'Notifications', 'StackService', 'ModalService', -function ($scope, $state, Notifications, StackService, ModalService) { - $scope.state = { - displayInformationPanel: false, - displayExternalStacks: true - }; - +angular.module('portainer.app') +.controller('StacksController', ['$scope', '$state', 'Notifications', 'StackService', 'ModalService', 'EndpointProvider', +function ($scope, $state, Notifications, StackService, ModalService, EndpointProvider) { $scope.removeAction = function(selectedItems) { ModalService.confirmDeletion( 'Do you want to remove the selected stack(s)? Associated services will be removed as well.', @@ -17,9 +12,10 @@ function ($scope, $state, Notifications, StackService, ModalService) { }; function deleteSelectedStacks(stacks) { + var endpointId = EndpointProvider.endpointID(); var actionCount = stacks.length; angular.forEach(stacks, function (stack) { - StackService.remove(stack) + StackService.remove(stack, stack.External, endpointId) .then(function success() { Notifications.success('Stack successfully removed', stack.Name); var index = $scope.stacks.indexOf(stack); @@ -38,16 +34,16 @@ function ($scope, $state, Notifications, StackService, ModalService) { } function initView() { - StackService.stacks(true) + var endpointMode = $scope.applicationState.endpoint.mode; + var endpointId = EndpointProvider.endpointID(); + + StackService.stacks( + true, + endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', + endpointId + ) .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) { diff --git a/app/portainer/views/support/support.html b/app/portainer/views/support/support.html new file mode 100644 index 000000000..b687d52de --- /dev/null +++ b/app/portainer/views/support/support.html @@ -0,0 +1,38 @@ + + + + + Portainer support + + + +
+
+ + + +
+

+ Portainer.io offers multiple commercial support options. +

+

+ Per incident +

+

+

+ Per Portainer instance +

    +
  • $USD 1200 per year
  • +
  • Unlimited incidents
  • +
  • 4 named users
  • +
  • Contact us
  • +
+

+
+
+
+
+
diff --git a/app/portainer/views/tags/tags.html b/app/portainer/views/tags/tags.html new file mode 100644 index 000000000..4d4b27a65 --- /dev/null +++ b/app/portainer/views/tags/tags.html @@ -0,0 +1,58 @@ + + + + + + + Tag management + + +
+
+ + + + +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
diff --git a/app/portainer/views/tags/tagsController.js b/app/portainer/views/tags/tagsController.js new file mode 100644 index 000000000..e8da81ec8 --- /dev/null +++ b/app/portainer/views/tags/tagsController.js @@ -0,0 +1,58 @@ +angular.module('portainer.app') +.controller('TagsController', ['$scope', '$state', 'TagService', 'Notifications', +function ($scope, $state, TagService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + + $scope.formValues = { + Name: '' + }; + + $scope.removeAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (tag) { + TagService.deleteTag(tag.Id) + .then(function success() { + Notifications.success('Tag successfully removed', tag.Name); + var index = $scope.tags.indexOf(tag); + $scope.tags.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to tag'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + $scope.createTag = function() { + var tagName = $scope.formValues.Name; + TagService.createTag(tagName) + .then(function success(data) { + Notifications.success('Tag successfully created', tagName); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create tag'); + }); + }; + + function initView() { + TagService.tags() + .then(function success(data) { + $scope.tags = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tags'); + $scope.tags = []; + }); + } + + initView(); +}]); diff --git a/app/portainer/views/teams/edit/team.html b/app/portainer/views/teams/edit/team.html index f0b4ca3be..dcd1eb1f8 100644 --- a/app/portainer/views/teams/edit/team.html +++ b/app/portainer/views/teams/edit/team.html @@ -1,5 +1,5 @@ - + Teams > {{ team.Name }} @@ -8,7 +8,7 @@
- +
@@ -37,7 +37,7 @@
- +
Items per page: diff --git a/app/portainer/views/teams/teams.html b/app/portainer/views/teams/teams.html index 66c4b6e54..ab1088faa 100644 --- a/app/portainer/views/teams/teams.html +++ b/app/portainer/views/teams/teams.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- +
@@ -67,7 +67,7 @@
- + Users > {{ user.Username }} @@ -8,7 +8,7 @@
- +
@@ -40,7 +40,7 @@
- + diff --git a/app/portainer/views/users/users.html b/app/portainer/views/users/users.html index 047721771..78ae271b9 100644 --- a/app/portainer/views/users/users.html +++ b/app/portainer/views/users/users.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -115,7 +115,7 @@