diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index c6ce7db61..f80fd5921 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -5,6 +5,9 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/edgegroup" + "github.com/portainer/portainer/api/bolt/edgestack" + "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/tunnelserver" "github.com/boltdb/bolt" @@ -36,28 +39,31 @@ const ( // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - path string - db *bolt.DB - checkForDataMigration bool - fileService portainer.FileService - RoleService *role.Service - DockerHubService *dockerhub.Service - EndpointGroupService *endpointgroup.Service - EndpointService *endpoint.Service - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - SettingsService *settings.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TeamService *team.Service - TemplateService *template.Service - TunnelServerService *tunnelserver.Service - UserService *user.Service - VersionService *version.Service - WebhookService *webhook.Service - ScheduleService *schedule.Service + path string + db *bolt.DB + checkForDataMigration bool + fileService portainer.FileService + RoleService *role.Service + DockerHubService *dockerhub.Service + EdgeGroupService *edgegroup.Service + EdgeStackService *edgestack.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + TemplateService *template.Service + TunnelServerService *tunnelserver.Service + UserService *user.Service + VersionService *version.Service + WebhookService *webhook.Service + ScheduleService *schedule.Service } // NewStore initializes a new Store and the associated services @@ -117,23 +123,24 @@ func (store *Store) MigrateData() error { if version < portainer.DBVersion { migratorParams := &migrator.Parameters{ - DB: store.db, - DatabaseVersion: version, - EndpointGroupService: store.EndpointGroupService, - EndpointService: store.EndpointService, - ExtensionService: store.ExtensionService, - RegistryService: store.RegistryService, - ResourceControlService: store.ResourceControlService, - RoleService: store.RoleService, - ScheduleService: store.ScheduleService, - SettingsService: store.SettingsService, - StackService: store.StackService, - TagService: store.TagService, - TeamMembershipService: store.TeamMembershipService, - TemplateService: store.TemplateService, - UserService: store.UserService, - VersionService: store.VersionService, - FileService: store.fileService, + DB: store.db, + DatabaseVersion: version, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + EndpointRelationService: store.EndpointRelationService, + ExtensionService: store.ExtensionService, + RegistryService: store.RegistryService, + ResourceControlService: store.ResourceControlService, + RoleService: store.RoleService, + ScheduleService: store.ScheduleService, + SettingsService: store.SettingsService, + StackService: store.StackService, + TagService: store.TagService, + TeamMembershipService: store.TeamMembershipService, + TemplateService: store.TemplateService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, } migrator := migrator.NewMigrator(migratorParams) @@ -161,6 +168,18 @@ func (store *Store) initServices() error { } store.DockerHubService = dockerhubService + edgeStackService, err := edgestack.NewService(store.db) + if err != nil { + return err + } + store.EdgeStackService = edgeStackService + + edgeGroupService, err := edgegroup.NewService(store.db) + if err != nil { + return err + } + store.EdgeGroupService = edgeGroupService + endpointgroupService, err := endpointgroup.NewService(store.db) if err != nil { return err @@ -173,6 +192,12 @@ func (store *Store) initServices() error { } store.EndpointService = endpointService + endpointRelationService, err := endpointrelation.NewService(store.db) + if err != nil { + return err + } + store.EndpointRelationService = endpointRelationService + extensionService, err := extension.NewService(store.db) if err != nil { return err diff --git a/api/bolt/edgegroup/edgegroup.go b/api/bolt/edgegroup/edgegroup.go new file mode 100644 index 000000000..41909b437 --- /dev/null +++ b/api/bolt/edgegroup/edgegroup.go @@ -0,0 +1,94 @@ +package edgegroup + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "edgegroups" +) + +// Service represents a service for managing Edge group 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 +} + +// EdgeGroups return an array containing all the Edge groups. +func (service *Service) EdgeGroups() ([]portainer.EdgeGroup, error) { + var groups = make([]portainer.EdgeGroup, 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 group portainer.EdgeGroup + err := internal.UnmarshalObjectWithJsoniter(v, &group) + if err != nil { + return err + } + groups = append(groups, group) + } + + return nil + }) + + return groups, err +} + +// EdgeGroup returns an Edge group by ID. +func (service *Service) EdgeGroup(ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { + var group portainer.EdgeGroup + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &group) + if err != nil { + return nil, err + } + + return &group, nil +} + +// UpdateEdgeGroup updates an Edge group. +func (service *Service) UpdateEdgeGroup(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, group) +} + +// DeleteEdgeGroup deletes an Edge group. +func (service *Service) DeleteEdgeGroup(ID portainer.EdgeGroupID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// CreateEdgeGroup assign an ID to a new Edge group and saves it. +func (service *Service) CreateEdgeGroup(group *portainer.EdgeGroup) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + group.ID = portainer.EdgeGroupID(id) + + data, err := internal.MarshalObject(group) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(group.ID)), data) + }) +} diff --git a/api/bolt/edgestack/edgestack.go b/api/bolt/edgestack/edgestack.go new file mode 100644 index 000000000..337bb6892 --- /dev/null +++ b/api/bolt/edgestack/edgestack.go @@ -0,0 +1,101 @@ +package edgestack + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "edge_stack" +) + +// Service represents a service for managing Edge stack 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 +} + +// EdgeStacks returns an array containing all edge stacks +func (service *Service) EdgeStacks() ([]portainer.EdgeStack, error) { + var stacks = make([]portainer.EdgeStack, 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.EdgeStack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + stacks = append(stacks, stack) + } + + return nil + }) + + return stacks, err +} + +// EdgeStack returns an Edge stack by ID. +func (service *Service) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) { + var stack portainer.EdgeStack + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &stack) + if err != nil { + return nil, err + } + + return &stack, nil +} + +// CreateEdgeStack assign an ID to a new Edge stack and saves it. +func (service *Service) CreateEdgeStack(edgeStack *portainer.EdgeStack) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + if edgeStack.ID == 0 { + id, _ := bucket.NextSequence() + edgeStack.ID = portainer.EdgeStackID(id) + } + + data, err := internal.MarshalObject(edgeStack) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(edgeStack.ID)), data) + }) +} + +// UpdateEdgeStack updates an Edge stack. +func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, edgeStack) +} + +// DeleteEdgeStack deletes an Edge stack. +func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// GetNextIdentifier returns the next identifier for an endpoint. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} diff --git a/api/bolt/endpointrelation/endpointrelation.go b/api/bolt/endpointrelation/endpointrelation.go new file mode 100644 index 000000000..00dab3f4a --- /dev/null +++ b/api/bolt/endpointrelation/endpointrelation.go @@ -0,0 +1,68 @@ +package endpointrelation + +import ( + "github.com/boltdb/bolt" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoint_relations" +) + +// Service represents a service for managing endpoint relation 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 +} + +// EndpointRelation returns a Endpoint relation object by EndpointID +func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*portainer.EndpointRelation, error) { + var endpointRelation portainer.EndpointRelation + identifier := internal.Itob(int(endpointID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpointRelation) + if err != nil { + return nil, err + } + + return &endpointRelation, nil +} + +// CreateEndpointRelation saves endpointRelation +func (service *Service) CreateEndpointRelation(endpointRelation *portainer.EndpointRelation) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(endpointRelation) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpointRelation.EndpointID)), data) + }) +} + +// UpdateEndpointRelation updates an Endpoint relation object +func (service *Service) UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { + identifier := internal.Itob(int(EndpointID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpointRelation) +} + +// DeleteEndpointRelation deletes an Endpoint relation object +func (service *Service) DeleteEndpointRelation(EndpointID portainer.EndpointID) error { + identifier := internal.Itob(int(EndpointID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/migrator/migrate_dbversion22.go b/api/bolt/migrator/migrate_dbversion22.go index f2e3c2b6c..4a132c348 100644 --- a/api/bolt/migrator/migrate_dbversion22.go +++ b/api/bolt/migrator/migrate_dbversion22.go @@ -2,15 +2,32 @@ package migrator import "github.com/portainer/portainer/api" -func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { +func (m *Migrator) updateTagsToDBVersion23() error { tags, err := m.tagService.Tags() if err != nil { return err } - tagsNameMap := make(map[string]portainer.TagID) for _, tag := range tags { - tagsNameMap[tag.Name] = tag.ID + tag.EndpointGroups = make(map[portainer.EndpointGroupID]bool) + tag.Endpoints = make(map[portainer.EndpointID]bool) + err = m.tagService.UpdateTag(tag.ID, &tag) + if err != nil { + return err + } + } + return nil +} + +func (m *Migrator) updateEndpointsAndEndpointGroupsToDBVersion23() error { + tags, err := m.tagService.Tags() + if err != nil { + return err + } + + tagsNameMap := make(map[string]portainer.Tag) + for _, tag := range tags { + tagsNameMap[tag.Name] = tag } endpoints, err := m.endpointService.Endpoints() @@ -21,9 +38,10 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { for _, endpoint := range endpoints { endpointTags := make([]portainer.TagID, 0) for _, tagName := range endpoint.Tags { - tagID, ok := tagsNameMap[tagName] + tag, ok := tagsNameMap[tagName] if ok { - endpointTags = append(endpointTags, tagID) + endpointTags = append(endpointTags, tag.ID) + tag.Endpoints[endpoint.ID] = true } } endpoint.TagIDs = endpointTags @@ -31,6 +49,16 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { if err != nil { return err } + + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err = m.endpointRelationService.CreateEndpointRelation(relation) + if err != nil { + return err + } } endpointGroups, err := m.endpointGroupService.EndpointGroups() @@ -41,9 +69,10 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { for _, endpointGroup := range endpointGroups { endpointGroupTags := make([]portainer.TagID, 0) for _, tagName := range endpointGroup.Tags { - tagID, ok := tagsNameMap[tagName] + tag, ok := tagsNameMap[tagName] if ok { - endpointGroupTags = append(endpointGroupTags, tagID) + endpointGroupTags = append(endpointGroupTags, tag.ID) + tag.EndpointGroups[endpointGroup.ID] = true } } endpointGroup.TagIDs = endpointGroupTags @@ -53,5 +82,11 @@ func (m *Migrator) updateEndointsAndEndpointsGroupsToDBVersion23() error { } } + for _, tag := range tagsNameMap { + err = m.tagService.UpdateTag(tag.ID, &tag) + if err != nil { + return err + } + } return nil } diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 8f4aee085..055ffcdda 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -5,6 +5,7 @@ import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/endpoint" "github.com/portainer/portainer/api/bolt/endpointgroup" + "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" @@ -22,67 +23,70 @@ import ( 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 - extensionService *extension.Service - registryService *registry.Service - resourceControlService *resourcecontrol.Service - roleService *role.Service - scheduleService *schedule.Service - settingsService *settings.Service - stackService *stack.Service - tagService *tag.Service - teamMembershipService *teammembership.Service - templateService *template.Service - userService *user.Service - versionService *version.Service - fileService portainer.FileService + currentDBVersion int + db *bolt.DB + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + endpointRelationService *endpointrelation.Service + extensionService *extension.Service + registryService *registry.Service + resourceControlService *resourcecontrol.Service + roleService *role.Service + scheduleService *schedule.Service + settingsService *settings.Service + stackService *stack.Service + tagService *tag.Service + teamMembershipService *teammembership.Service + templateService *template.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 - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - RoleService *role.Service - ScheduleService *schedule.Service - SettingsService *settings.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TemplateService *template.Service - UserService *user.Service - VersionService *version.Service - FileService portainer.FileService + DB *bolt.DB + DatabaseVersion int + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TemplateService *template.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, - extensionService: parameters.ExtensionService, - registryService: parameters.RegistryService, - resourceControlService: parameters.ResourceControlService, - roleService: parameters.RoleService, - scheduleService: parameters.ScheduleService, - settingsService: parameters.SettingsService, - tagService: parameters.TagService, - teamMembershipService: parameters.TeamMembershipService, - templateService: parameters.TemplateService, - stackService: parameters.StackService, - userService: parameters.UserService, - versionService: parameters.VersionService, - fileService: parameters.FileService, + db: parameters.DB, + currentDBVersion: parameters.DatabaseVersion, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + endpointRelationService: parameters.EndpointRelationService, + extensionService: parameters.ExtensionService, + registryService: parameters.RegistryService, + resourceControlService: parameters.ResourceControlService, + roleService: parameters.RoleService, + scheduleService: parameters.ScheduleService, + settingsService: parameters.SettingsService, + tagService: parameters.TagService, + teamMembershipService: parameters.TeamMembershipService, + templateService: parameters.TemplateService, + stackService: parameters.StackService, + userService: parameters.UserService, + versionService: parameters.VersionService, + fileService: parameters.FileService, } } @@ -307,7 +311,12 @@ func (m *Migrator) Migrate() error { // Portainer 1.24.0-dev if m.currentDBVersion < 23 { - err := m.updateEndointsAndEndpointsGroupsToDBVersion23() + err := m.updateTagsToDBVersion23() + if err != nil { + return err + } + + err = m.updateEndpointsAndEndpointGroupsToDBVersion23() if err != nil { return err } diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go index d4a5dc9de..ba0f44ba9 100644 --- a/api/bolt/tag/tag.go +++ b/api/bolt/tag/tag.go @@ -82,6 +82,12 @@ func (service *Service) CreateTag(tag *portainer.Tag) error { }) } +// UpdateTag updates a tag. +func (service *Service) UpdateTag(ID portainer.TagID, tag *portainer.Tag) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, tag) +} + // DeleteTag deletes a tag. func (service *Service) DeleteTag(ID portainer.TagID) error { identifier := internal.Itob(int(ID)) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 46c5bb5ca..a606fefd4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -651,44 +651,47 @@ func main() { } var server portainer.Server = &http.Server{ - ReverseTunnelService: reverseTunnelService, - Status: applicationStatus, - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - AuthDisabled: *flags.NoAuth, - EndpointManagement: endpointManagement, - RoleService: store.RoleService, - UserService: store.UserService, - TeamService: store.TeamService, - TeamMembershipService: store.TeamMembershipService, - EndpointService: store.EndpointService, - EndpointGroupService: store.EndpointGroupService, - ExtensionService: store.ExtensionService, - ResourceControlService: store.ResourceControlService, - SettingsService: store.SettingsService, - RegistryService: store.RegistryService, - DockerHubService: store.DockerHubService, - StackService: store.StackService, - ScheduleService: store.ScheduleService, - TagService: store.TagService, - TemplateService: store.TemplateService, - WebhookService: store.WebhookService, - SwarmStackManager: swarmStackManager, - ComposeStackManager: composeStackManager, - ExtensionManager: extensionManager, - CryptoService: cryptoService, - JWTService: jwtService, - FileService: fileService, - LDAPService: ldapService, - GitService: gitService, - SignatureService: digitalSignatureService, - JobScheduler: jobScheduler, - Snapshotter: snapshotter, - SSL: *flags.SSL, - SSLCert: *flags.SSLCert, - SSLKey: *flags.SSLKey, - DockerClientFactory: clientFactory, - JobService: jobService, + ReverseTunnelService: reverseTunnelService, + Status: applicationStatus, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + AuthDisabled: *flags.NoAuth, + EndpointManagement: endpointManagement, + RoleService: store.RoleService, + UserService: store.UserService, + TeamService: store.TeamService, + TeamMembershipService: store.TeamMembershipService, + EdgeGroupService: store.EdgeGroupService, + EdgeStackService: store.EdgeStackService, + EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, + EndpointRelationService: store.EndpointRelationService, + ExtensionService: store.ExtensionService, + ResourceControlService: store.ResourceControlService, + SettingsService: store.SettingsService, + RegistryService: store.RegistryService, + DockerHubService: store.DockerHubService, + StackService: store.StackService, + ScheduleService: store.ScheduleService, + TagService: store.TagService, + TemplateService: store.TemplateService, + WebhookService: store.WebhookService, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, + ExtensionManager: extensionManager, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, + LDAPService: ldapService, + GitService: gitService, + SignatureService: digitalSignatureService, + JobScheduler: jobScheduler, + Snapshotter: snapshotter, + SSL: *flags.SSL, + SSLCert: *flags.SSLCert, + SSLKey: *flags.SSLKey, + DockerClientFactory: clientFactory, + JobService: jobService, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/edgegroup.go b/api/edgegroup.go new file mode 100644 index 000000000..d68ee7a9b --- /dev/null +++ b/api/edgegroup.go @@ -0,0 +1,54 @@ +package portainer + +// EdgeGroupRelatedEndpoints returns a list of endpoints related to this Edge group +func EdgeGroupRelatedEndpoints(edgeGroup *EdgeGroup, endpoints []Endpoint, endpointGroups []EndpointGroup) []EndpointID { + if !edgeGroup.Dynamic { + return edgeGroup.Endpoints + } + + endpointIDs := []EndpointID{} + for _, endpoint := range endpoints { + if endpoint.Type != EdgeAgentEnvironment { + continue + } + + var endpointGroup EndpointGroup + for _, group := range endpointGroups { + if endpoint.GroupID == group.ID { + endpointGroup = group + break + } + } + + if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, &endpointGroup) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + + return endpointIDs +} + +// edgeGroupRelatedToEndpoint returns true is edgeGroup is associated with endpoint +func edgeGroupRelatedToEndpoint(edgeGroup *EdgeGroup, endpoint *Endpoint, endpointGroup *EndpointGroup) bool { + if !edgeGroup.Dynamic { + for _, endpointID := range edgeGroup.Endpoints { + if endpoint.ID == endpointID { + return true + } + } + return false + } + + endpointTags := TagSet(endpoint.TagIDs) + if endpointGroup.TagIDs != nil { + endpointTags = TagUnion(endpointTags, TagSet(endpointGroup.TagIDs)) + } + edgeGroupTags := TagSet(edgeGroup.TagIDs) + + if edgeGroup.PartialMatch { + intersection := TagIntersection(endpointTags, edgeGroupTags) + return len(intersection) != 0 + } + + return TagContains(edgeGroupTags, endpointTags) +} diff --git a/api/edgestack.go b/api/edgestack.go new file mode 100644 index 000000000..7a3019c5d --- /dev/null +++ b/api/edgestack.go @@ -0,0 +1,27 @@ +package portainer + +import "errors" + +// EdgeStackRelatedEndpoints returns a list of endpoints related to this Edge stack +func EdgeStackRelatedEndpoints(edgeGroupIDs []EdgeGroupID, endpoints []Endpoint, endpointGroups []EndpointGroup, edgeGroups []EdgeGroup) ([]EndpointID, error) { + edgeStackEndpoints := []EndpointID{} + + for _, edgeGroupID := range edgeGroupIDs { + var edgeGroup *EdgeGroup + + for _, group := range edgeGroups { + if group.ID == edgeGroupID { + edgeGroup = &group + break + } + } + + if edgeGroup == nil { + return nil, errors.New("Edge group was not found") + } + + edgeStackEndpoints = append(edgeStackEndpoints, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...) + } + + return edgeStackEndpoints, nil +} diff --git a/api/endpoint.go b/api/endpoint.go new file mode 100644 index 000000000..661818da3 --- /dev/null +++ b/api/endpoint.go @@ -0,0 +1,25 @@ +package portainer + +// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Endpoint +func EndpointRelatedEdgeStacks(endpoint *Endpoint, endpointGroup *EndpointGroup, edgeGroups []EdgeGroup, edgeStacks []EdgeStack) []EdgeStackID { + relatedEdgeGroupsSet := map[EdgeGroupID]bool{} + + for _, edgeGroup := range edgeGroups { + if edgeGroupRelatedToEndpoint(&edgeGroup, endpoint, endpointGroup) { + relatedEdgeGroupsSet[edgeGroup.ID] = true + } + } + + relatedEdgeStacks := []EdgeStackID{} + for _, edgeStack := range edgeStacks { + for _, edgeGroupID := range edgeStack.EdgeGroups { + if relatedEdgeGroupsSet[edgeGroupID] { + relatedEdgeStacks = append(relatedEdgeStacks, edgeStack.ID) + break + } + } + } + + return relatedEdgeStacks + +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index c2ee9be3a..d65ad4f8d 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -29,6 +29,8 @@ const ( ComposeStorePath = "compose" // ComposeFileDefaultName represents the default name of a compose file. ComposeFileDefaultName = "docker-compose.yml" + // EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder. + EdgeStackStorePath = "edge_stacks" // PrivateKeyFile represents the name on disk of the file containing the private key. PrivateKeyFile = "portainer.key" // PublicKeyFile represents the name on disk of the file containing the public key. @@ -121,6 +123,32 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string return path.Join(service.fileStorePath, stackStorePath), nil } +// GetEdgeStackProjectPath returns the absolute path on the FS for a edge stack based +// on its identifier. +func (service *Service) GetEdgeStackProjectPath(edgeStackIdentifier string) string { + return path.Join(service.fileStorePath, EdgeStackStorePath, edgeStackIdentifier) +} + +// StoreEdgeStackFileFromBytes creates a subfolder in the EdgeStackStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreEdgeStackFileFromBytes(edgeStackIdentifier, fileName string, data []byte) (string, error) { + stackStorePath := path.Join(EdgeStackStorePath, edgeStackIdentifier) + err := service.createDirectoryInStore(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := path.Join(stackStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, stackStorePath), nil +} + // StoreRegistryManagementFileFromBytes creates a subfolder in the // ExtensionRegistryManagementStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go new file mode 100644 index 000000000..8ff2f7693 --- /dev/null +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -0,0 +1,104 @@ +package edgegroups + +import ( + "github.com/portainer/portainer/api" +) + +type endpointSetType map[portainer.EndpointID]bool + +func (handler *Handler) getEndpointsByTags(tagIDs []portainer.TagID, partialMatch bool) ([]portainer.EndpointID, error) { + if len(tagIDs) == 0 { + return []portainer.EndpointID{}, nil + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return nil, err + } + + groupEndpoints := mapEndpointGroupToEndpoints(endpoints) + + tags := []portainer.Tag{} + for _, tagID := range tagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return nil, err + } + tags = append(tags, *tag) + } + + setsOfEndpoints := mapTagsToEndpoints(tags, groupEndpoints) + + var endpointSet endpointSetType + if partialMatch { + endpointSet = setsUnion(setsOfEndpoints) + } else { + endpointSet = setsIntersection(setsOfEndpoints) + } + + results := []portainer.EndpointID{} + for _, endpoint := range endpoints { + if _, ok := endpointSet[endpoint.ID]; ok && endpoint.Type == portainer.EdgeAgentEnvironment { + results = append(results, endpoint.ID) + } + } + + return results, nil +} + +func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType { + groupEndpoints := map[portainer.EndpointGroupID]endpointSetType{} + for _, endpoint := range endpoints { + groupID := endpoint.GroupID + if groupEndpoints[groupID] == nil { + groupEndpoints[groupID] = endpointSetType{} + } + groupEndpoints[groupID][endpoint.ID] = true + } + return groupEndpoints +} + +func mapTagsToEndpoints(tags []portainer.Tag, groupEndpoints map[portainer.EndpointGroupID]endpointSetType) []endpointSetType { + sets := []endpointSetType{} + for _, tag := range tags { + set := tag.Endpoints + for groupID := range tag.EndpointGroups { + for endpointID := range groupEndpoints[groupID] { + set[endpointID] = true + } + } + sets = append(sets, set) + } + + return sets +} + +func setsIntersection(sets []endpointSetType) endpointSetType { + if len(sets) == 0 { + return endpointSetType{} + } + + intersectionSet := sets[0] + + for _, set := range sets { + for endpointID := range intersectionSet { + if !set[endpointID] { + delete(intersectionSet, endpointID) + } + } + } + + return intersectionSet +} + +func setsUnion(sets []endpointSetType) endpointSetType { + unionSet := endpointSetType{} + + for _, set := range sets { + for endpointID := range set { + unionSet[endpointID] = true + } + } + + return unionSet +} diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go new file mode 100644 index 000000000..26bbd0d90 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -0,0 +1,83 @@ +package edgegroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type edgeGroupCreatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch bool +} + +func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid Edge group name") + } + if payload.Dynamic && (payload.TagIDs == nil || len(payload.TagIDs) == 0) { + return portainer.Error("TagIDs is mandatory for a dynamic Edge group") + } + if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { + return portainer.Error("Endpoints is mandatory for a static Edge group") + } + return nil +} + +func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload edgeGroupCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name { + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", portainer.Error("Edge group name must be unique")} + } + } + + edgeGroup := &portainer.EdgeGroup{ + Name: payload.Name, + Dynamic: payload.Dynamic, + TagIDs: []portainer.TagID{}, + Endpoints: []portainer.EndpointID{}, + PartialMatch: payload.PartialMatch, + } + + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs + } else { + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs + } + + err = handler.EdgeGroupService.CreateEdgeGroup(edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Edge group inside the database", err} + } + + return response.JSON(w, edgeGroup) +} diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go new file mode 100644 index 000000000..8ad9e949f --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -0,0 +1,45 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + _, err = handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} + } + + for _, edgeStack := range edgeStacks { + for _, groupID := range edgeStack.EdgeGroups { + if groupID == portainer.EdgeGroupID(edgeGroupID) { + return &httperror.HandlerError{http.StatusForbidden, "Edge group is used by an Edge stack", portainer.Error("Edge group is used by an Edge stack")} + } + } + } + + err = handler.EdgeGroupService.DeleteEdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge group from the database", err} + } + + return response.Empty(w) + +} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go new file mode 100644 index 000000000..5fdadf2ec --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -0,0 +1,35 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + if edgeGroup.Dynamic { + endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + } + + edgeGroup.Endpoints = endpoints + } + + return response.JSON(w, edgeGroup) +} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go new file mode 100644 index 000000000..f859300aa --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -0,0 +1,55 @@ +package edgegroups + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type decoratedEdgeGroup struct { + portainer.EdgeGroup + HasEdgeStack bool `json:"HasEdgeStack"` +} + +func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} + } + + usedEdgeGroups := make(map[portainer.EdgeGroupID]bool) + + for _, stack := range edgeStacks { + for _, groupID := range stack.EdgeGroups { + usedEdgeGroups[groupID] = true + } + } + + decoratedEdgeGroups := []decoratedEdgeGroup{} + for _, orgEdgeGroup := range edgeGroups { + edgeGroup := decoratedEdgeGroup{ + EdgeGroup: orgEdgeGroup, + } + if edgeGroup.Dynamic { + endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + } + + edgeGroup.Endpoints = endpoints + } + + edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID] + + decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup) + } + + return response.JSON(w, decoratedEdgeGroups) +} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go new file mode 100644 index 000000000..d6a72ea6d --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -0,0 +1,154 @@ +package edgegroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type edgeGroupUpdatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch *bool +} + +func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid Edge group name") + } + if payload.Dynamic && (payload.TagIDs == nil || len(payload.TagIDs) == 0) { + return portainer.Error("TagIDs is mandatory for a dynamic Edge group") + } + if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { + return portainer.Error("Endpoints is mandatory for a static Edge group") + } + return nil +} + +func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} + } + + var payload edgeGroupUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge group with the specified identifier inside the database", err} + } + + if payload.Name != "" { + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} + } + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", portainer.Error("Edge group name must be unique")} + } + } + + edgeGroup.Name = payload.Name + } + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + oldRelatedEndpoints := portainer.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + + edgeGroup.Dynamic = payload.Dynamic + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs + } else { + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs + } + + if payload.PartialMatch != nil { + edgeGroup.PartialMatch = *payload.PartialMatch + } + + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge group changes inside the database", err} + } + + newRelatedEndpoints := portainer.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) + + for _, endpointID := range endpointsToUpdate { + err = handler.updateEndpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Endpoint relation changes inside the database", err} + } + } + + return response.JSON(w, edgeGroup) +} + +func (handler *Handler) updateEndpoint(endpointID portainer.EndpointID) error { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return err + } + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return err + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return err + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + edgeStackSet := map[portainer.EdgeStackID]bool{} + + endpointEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, edgeStackID := range endpointEdgeStacks { + edgeStackSet[edgeStackID] = true + } + + relation.EdgeStacks = edgeStackSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) +} diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go new file mode 100644 index 000000000..874b13477 --- /dev/null +++ b/api/http/handler/edgegroups/handler.go @@ -0,0 +1,39 @@ +package edgegroups + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + TagService portainer.TagService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/edge_groups", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupCreate))).Methods(http.MethodPost) + h.Handle("/edge_groups", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupList))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupInspect))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupUpdate))).Methods(http.MethodPut) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupDelete))).Methods(http.MethodDelete) + return h +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go new file mode 100644 index 000000000..e3a315cb9 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -0,0 +1,289 @@ +package edgestacks + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" +) + +// POST request on /api/endpoint_groups +func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + edgeStack, err := handler.createSwarmStack(method, r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + relatedEndpoints, err := portainer.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + + for _, endpointID := range relatedEndpoints { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + relation.EdgeStacks[edgeStack.ID] = true + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + return response.JSON(w, edgeStack) +} + +func (handler *Handler) createSwarmStack(method string, r *http.Request) (*portainer.EdgeStack, error) { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(r) + case "repository": + return handler.createSwarmStackFromGitRepository(r) + case "file": + return handler.createSwarmStackFromFileUpload(r) + } + return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file") +} + +type swarmStackFromFileContentPayload struct { + Name string + StackFileContent string + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *swarmStackFromFileContentPayload) 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") + } + if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*portainer.EdgeStack, error) { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: filesystem.ComposeFileDefaultName, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +type swarmStackFromGitRepositoryPayload struct { + Name string + RepositoryURL string + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *swarmStackFromGitRepositoryPayload) 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 + } + if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*portainer.EdgeStack, error) { + var payload swarmStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: payload.ComposeFilePathInRepository, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + referenceName: payload.RepositoryReferenceName, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return nil, err + } + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +type swarmStackFromFileUploadPayload struct { + Name string + StackFileContent []byte + EdgeGroups []portainer.EdgeGroupID +} + +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 + + 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 edgeGroups []portainer.EdgeGroupID + err = request.RetrieveMultiPartFormJSONValue(r, "EdgeGroups", &edgeGroups, false) + if err != nil || len(edgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + payload.EdgeGroups = edgeGroups + return nil +} + +func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portainer.EdgeStack, error) { + payload := &swarmStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return nil, err + } + + err = handler.validateUniqueName(payload.Name) + if err != nil { + return nil, err + } + + stackID := handler.EdgeStackService.GetNextIdentifier() + stack := &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + EntryPoint: filesystem.ComposeFileDefaultName, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.EdgeStackService.CreateEdgeStack(stack) + if err != nil { + return nil, err + } + + return stack, nil +} + +func (handler *Handler) validateUniqueName(name string) error { + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + for _, stack := range edgeStacks { + if strings.EqualFold(stack.Name, name) { + return portainer.Error("Edge stack name must be unique") + } + } + return nil +} diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go new file mode 100644 index 000000000..ae5d1b476 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -0,0 +1,62 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + err = handler.EdgeStackService.DeleteEdgeStack(portainer.EdgeStackID(edgeStackID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the edge stack from the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + relatedEndpoints, err := portainer.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + + for _, endpointID := range relatedEndpoints { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + delete(relation.EdgeStacks, edgeStack.ID) + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go new file mode 100644 index 000000000..c82348b8d --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -0,0 +1,37 @@ +package edgestacks + +import ( + "net/http" + "path" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// GET request on /api/edge_stacks/:id/file +func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + 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: string(stackFileContent)}) +} diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go new file mode 100644 index 000000000..66a591633 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -0,0 +1,26 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + return response.JSON(w, edgeStack) +} diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go new file mode 100644 index 000000000..dd15e58c1 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -0,0 +1,17 @@ +package edgestacks + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + return response.JSON(w, edgeStacks) +} diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go new file mode 100644 index 000000000..bcf0a639b --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -0,0 +1,76 @@ +package edgestacks + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type updateStatusPayload struct { + Error string + Status *portainer.EdgeStackStatusType + EndpointID *portainer.EndpointID +} + +func (payload *updateStatusPayload) Validate(r *http.Request) error { + if payload.Status == nil { + return portainer.Error("Invalid status") + } + if payload.EndpointID == nil { + return portainer.Error("Invalid EndpointID") + } + if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) { + return portainer.Error("Error message is mandatory when status is error") + } + return nil +} + +func (handler *Handler) edgeStackStatusUpdate(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.EdgeStackService.EdgeStack(portainer.EdgeStackID(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} + } + + var payload updateStatusPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, 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} + } + + err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{ + Type: *payload.Status, + Error: payload.Error, + EndpointID: *payload.EndpointID, + } + + err = handler.EdgeStackService.UpdateEdgeStack(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) + +} diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go new file mode 100644 index 000000000..404ff1d92 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -0,0 +1,156 @@ +package edgestacks + +import ( + "net/http" + "strconv" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" +) + +type updateEdgeStackPayload struct { + StackFileContent string + Version *int + Prune *bool + EdgeGroups []portainer.EdgeGroupID +} + +func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 { + return portainer.Error("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +func (handler *Handler) edgeStackUpdate(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.EdgeStackService.EdgeStack(portainer.EdgeStackID(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} + } + + var payload updateEdgeStackPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if payload.EdgeGroups != nil { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} + } + + oldRelated, err := portainer.EdgeStackRelatedEndpoints(stack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} + } + + newRelated, err := portainer.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} + } + + oldRelatedSet := EndpointSet(oldRelated) + newRelatedSet := EndpointSet(newRelated) + + endpointsToRemove := map[portainer.EndpointID]bool{} + for endpointID := range oldRelatedSet { + if !newRelatedSet[endpointID] { + endpointsToRemove[endpointID] = true + } + } + + for endpointID := range endpointsToRemove { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + delete(relation.EdgeStacks, stack.ID) + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + endpointsToAdd := map[portainer.EndpointID]bool{} + for endpointID := range newRelatedSet { + if !oldRelatedSet[endpointID] { + endpointsToAdd[endpointID] = true + } + } + + for endpointID := range endpointsToAdd { + relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + } + + relation.EdgeStacks[stack.ID] = true + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + } + } + + stack.EdgeGroups = payload.EdgeGroups + + } + + if payload.Prune != nil { + stack.Prune = *payload.Prune + } + + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + if payload.Version != nil && *payload.Version != stack.Version { + stack.Version = *payload.Version + stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} + } + + err = handler.EdgeStackService.UpdateEdgeStack(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 EndpointSet(endpointIDs []portainer.EndpointID) map[portainer.EndpointID]bool { + set := map[portainer.EndpointID]bool{} + + for _, endpointID := range endpointIDs { + set[endpointID] = true + } + + return set +} diff --git a/api/http/handler/edgestacks/git.go b/api/http/handler/edgestacks/git.go new file mode 100644 index 000000000..855fa72bc --- /dev/null +++ b/api/http/handler/edgestacks/git.go @@ -0,0 +1,17 @@ +package edgestacks + +type cloneRepositoryParameters struct { + url string + referenceName 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.referenceName, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path) +} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go new file mode 100644 index 000000000..45c823e5c --- /dev/null +++ b/api/http/handler/edgestacks/handler.go @@ -0,0 +1,46 @@ +package edgestacks + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + FileService portainer.FileService + GitService portainer.GitService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + h.Handle("/edge_stacks", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackCreate))).Methods(http.MethodPost) + h.Handle("/edge_stacks", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackList))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackInspect))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackUpdate))).Methods(http.MethodPut) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackDelete))).Methods(http.MethodDelete) + h.Handle("/edge_stacks/{id}/file", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackFile))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut) + return h +} diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go new file mode 100644 index 000000000..4b82f19c2 --- /dev/null +++ b/api/http/handler/edgetemplates/edgetemplate_list.go @@ -0,0 +1,49 @@ +package edgetemplates + +import ( + "encoding/json" + "log" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" +) + +// GET request on /api/edgetemplates +func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + url := portainer.EdgeTemplatesURL + if settings.TemplatesURL != "" { + url = settings.TemplatesURL + } + + var templateData []byte + templateData, err = client.Get(url, 0) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} + } + + var templates []portainer.Template + + err = json.Unmarshal(templateData, &templates) + if err != nil { + log.Printf("[DEBUG] [http,edge,templates] [failed parsing edge templates] [body: %s]", templateData) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external templates", err} + } + + filteredTemplates := []portainer.Template{} + + for _, template := range templates { + if template.Type == portainer.EdgeStackTemplate { + filteredTemplates = append(filteredTemplates, template) + } + } + + return response.JSON(w, filteredTemplates) +} diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go new file mode 100644 index 000000000..75473c49a --- /dev/null +++ b/api/http/handler/edgetemplates/handler.go @@ -0,0 +1,31 @@ +package edgetemplates + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle edge endpoint operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + + h.Handle("/edge_templates", + bouncer.AdminAccess(httperror.LoggerHandler(h.edgeTemplateList))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go new file mode 100644 index 000000000..3853e3bba --- /dev/null +++ b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go @@ -0,0 +1,60 @@ +package endpointedge + +import ( + "net/http" + "path" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type configResponse struct { + Prune bool + StackFileContent string + Name string +} + +// GET request on api/endpoints/:id/edge/stacks/:stackId +func (handler *Handler) endpointEdgeStackInspect(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.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} + } + + edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, configResponse{ + Prune: edgeStack.Prune, + StackFileContent: string(stackFileContent), + Name: edgeStack.Name, + }) +} diff --git a/api/http/handler/endpointedge/handler.go b/api/http/handler/endpointedge/handler.go new file mode 100644 index 000000000..e8dfc2995 --- /dev/null +++ b/api/http/handler/endpointedge/handler.go @@ -0,0 +1,33 @@ +package endpointedge + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + + "github.com/gorilla/mux" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle edge endpoint operations. +type Handler struct { + *mux.Router + requestBouncer *security.RequestBouncer + EndpointService portainer.EndpointService + EdgeStackService portainer.EdgeStackService + FileService portainer.FileService +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + + h.Handle("/{id}/edge/stacks/{stackId}", + bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index 34bdbe754..f296fee64 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -63,10 +63,29 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } + err = handler.updateEndpointRelations(&endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + break } } } + for _, tagID := range endpointGroup.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tag from the database", err} + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index dbb634eff..76cd4ce86 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -20,7 +20,7 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} } - _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + 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 { @@ -46,6 +46,11 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } + + err = handler.updateEndpointRelations(&endpoint, nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } } } @@ -56,5 +61,19 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + for _, tagID := range endpointGroup.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tag from the database", err} + } + + delete(tag.EndpointGroups, endpointGroup.ID) + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index c67f730e0..f2435ab33 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -42,5 +42,10 @@ func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + err = handler.updateEndpointRelations(endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index 2054b428f..0e4a21611 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -42,5 +42,10 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + err = handler.updateEndpointRelations(endpoint, nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + return response.Empty(w) } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index ef7b4edc4..362b8b697 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -50,8 +50,44 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.Description = payload.Description } + tagsChanged := false if payload.TagIDs != nil { - endpointGroup.TagIDs = payload.TagIDs + payloadTagSet := portainer.TagSet(payload.TagIDs) + endpointGroupTagSet := portainer.TagSet((endpointGroup.TagIDs)) + union := portainer.TagUnion(payloadTagSet, endpointGroupTagSet) + intersection := portainer.TagIntersection(payloadTagSet, endpointGroupTagSet) + tagsChanged = len(union) > len(intersection) + + if tagsChanged { + removeTags := portainer.TagDifference(endpointGroupTagSet, payloadTagSet) + + for tagID := range removeTags { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + delete(tag.EndpointGroups, endpointGroup.ID) + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + + endpointGroup.TagIDs = payload.TagIDs + for _, tagID := range payload.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + } } updateAuthorizations := false @@ -77,5 +113,22 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } } + if tagsChanged { + 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 == endpointGroup.ID { + err = handler.updateEndpointRelations(&endpoint, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + } + } + } + } + return response.JSON(w, endpointGroup) } diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go new file mode 100644 index 000000000..11e760e15 --- /dev/null +++ b/api/http/handler/endpointgroups/endpoints.go @@ -0,0 +1,42 @@ +package endpointgroups + +import portainer "github.com/portainer/portainer/api" + +func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) error { + if endpoint.Type != portainer.EdgeAgentEnvironment { + return nil + } + + if endpointGroup == nil { + unassignedGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(1)) + if err != nil { + return err + } + + endpointGroup = unassignedGroup + } + + endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return err + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return err + } + + endpointStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + endpointRelation.EdgeStacks = stacksSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, endpointRelation) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index d4a36d3f5..a738a2dc1 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -12,9 +12,13 @@ import ( // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - AuthorizationService *portainer.AuthorizationService + AuthorizationService *portainer.AuthorizationService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + TagService portainer.TagService } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index dfa030927..b7d08f320 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -146,6 +146,38 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * return endpointCreationError } + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group inside the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + relationObject := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + if endpoint.Type == portainer.EdgeAgentEnvironment { + relatedEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, stackID := range relatedEdgeStacks { + relationObject.EdgeStacks[stackID] = true + } + } + + err = handler.EndpointRelationService.CreateEndpointRelation(relationObject) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the relation object inside the database", err} + } + return response.JSON(w, endpoint) } @@ -377,6 +409,20 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer. return handler.AuthorizationService.UpdateUsersAuthorizations() } + for _, tagID := range endpoint.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return err + } + + tag.Endpoints[endpoint.ID] = true + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return err + } + } + return nil } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index acd85e03c..43b20dc78 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -50,5 +50,75 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } + err = handler.EndpointRelationService.DeleteEndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint relation from the database", err} + } + + for _, tagID := range endpoint.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find tag inside the database", err} + } + + delete(tag.Endpoints, endpoint.ID) + + err = handler.TagService.UpdateTag(tagID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag relation inside the database", err} + } + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + for idx := range edgeGroups { + edgeGroup := &edgeGroups[idx] + endpointIdx := findEndpointIndex(edgeGroup.Endpoints, endpoint.ID) + if endpointIdx != -1 { + edgeGroup.Endpoints = removeElement(edgeGroup.Endpoints, endpointIdx) + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge group", err} + } + } + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + for idx := range edgeStacks { + edgeStack := &edgeStacks[idx] + if _, ok := edgeStack.Status[endpoint.ID]; ok { + delete(edgeStack.Status, endpoint.ID) + err = handler.EdgeStackService.UpdateEdgeStack(edgeStack.ID, edgeStack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge stack", err} + } + } + } + return response.Empty(w) } + +func findEndpointIndex(tags []portainer.EndpointID, searchEndpointID portainer.EndpointID) int { + for idx, tagID := range tags { + if searchEndpointID == tagID { + return idx + } + } + return -1 +} + +func removeElement(arr []portainer.EndpointID, index int) []portainer.EndpointID { + if index < 0 { + return arr + } + lastTagIdx := len(arr) - 1 + arr[index] = arr[lastTagIdx] + return arr[:lastTagIdx] +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index c68f8d9ec..57ffacbce 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -33,6 +33,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht var tagIDs []portainer.TagID request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true) + tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true) + var endpointIDs []portainer.EndpointID request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true) @@ -62,7 +64,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht } if search != "" { - tags, err := handler.TagsService.Tags() + tags, err := handler.TagService.Tags() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} } @@ -78,7 +80,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht } if tagIDs != nil { - filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups) + filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch) } filteredEndpointCount := len(filteredEndpoints) @@ -202,28 +204,26 @@ func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer. return tags } -func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup) []portainer.Endpoint { +func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint { filteredEndpoints := make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - missingTags := make(map[portainer.TagID]bool) - for _, tagID := range tagIDs { - missingTags[tagID] = true + endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups) + endpointMatched := false + if partialMatch { + endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs) + } else { + endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs) } - for _, tagID := range endpoint.TagIDs { - if missingTags[tagID] { - delete(missingTags, tagID) - } - } - missingTags = endpointGroupHasTags(endpoint.GroupID, endpointGroups, missingTags) - if len(missingTags) == 0 { + + if endpointMatched { filteredEndpoints = append(filteredEndpoints, endpoint) } } return filteredEndpoints } -func endpointGroupHasTags(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup, missingTags map[portainer.TagID]bool) map[portainer.TagID]bool { +func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup { var endpointGroup portainer.EndpointGroup for _, group := range groups { if group.ID == groupID { @@ -231,12 +231,43 @@ func endpointGroupHasTags(groupID portainer.EndpointGroupID, groups []portainer. break } } + return endpointGroup +} + +func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + tagSet := make(map[portainer.TagID]bool) + for _, tagID := range tagIDs { + tagSet[tagID] = true + } + for _, tagID := range endpoint.TagIDs { + if tagSet[tagID] { + return true + } + } + for _, tagID := range endpointGroup.TagIDs { + if tagSet[tagID] { + return true + } + } + return false +} + +func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + missingTags := make(map[portainer.TagID]bool) + for _, tagID := range tagIDs { + missingTags[tagID] = true + } + for _, tagID := range endpoint.TagIDs { + if missingTags[tagID] { + delete(missingTags, tagID) + } + } for _, tagID := range endpointGroup.TagIDs { if missingTags[tagID] { delete(missingTags, tagID) } } - return missingTags + return len(missingTags) == 0 } func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint { diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index da34a3bfe..13ae819a0 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -1,7 +1,6 @@ package endpoints import ( - "errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -10,12 +9,18 @@ import ( "github.com/portainer/portainer/api" ) +type stackStatusResponse struct { + ID portainer.EdgeStackID + Version int +} + type endpointStatusInspectResponse struct { Status string `json:"status"` Port int `json:"port"` Schedules []portainer.EdgeSchedule `json:"schedules"` CheckinInterval int `json:"checkin"` Credentials string `json:"credentials"` + Stacks []stackStatusResponse `json:"stacks"` } // GET request on /api/endpoints/:id/status @@ -32,20 +37,14 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if endpoint.Type != portainer.EdgeAgentEnvironment { - return &httperror.HandlerError{http.StatusInternalServerError, "Status unavailable for non Edge agent endpoints", errors.New("Status unavailable")} - } - - edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) - if edgeIdentifier == "" { - return &httperror.HandlerError{http.StatusForbidden, "Missing Edge identifier", errors.New("missing Edge identifier")} - } - - if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { - return &httperror.HandlerError{http.StatusForbidden, "Invalid Edge identifier", errors.New("invalid Edge identifier")} + err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } if endpoint.EdgeID == "" { + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + endpoint.EdgeID = edgeIdentifier err := handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) @@ -73,5 +72,27 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID) } + relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve relation object from the database", err} + } + + edgeStacksStatus := []stackStatusResponse{} + for stackID := range relation.EdgeStacks { + stack, err := handler.EdgeStackService.EdgeStack(stackID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack from the database", err} + } + + stackStatus := stackStatusResponse{ + ID: stack.ID, + Version: stack.Version, + } + + edgeStacksStatus = append(edgeStacksStatus, stackStatus) + } + + statusResponse.Stacks = edgeStacksStatus + return response.JSON(w, statusResponse) } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 7c02f5f67..2cc521a47 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -69,12 +69,52 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.PublicURL = *payload.PublicURL } + groupIDChanged := false if payload.GroupID != nil { - endpoint.GroupID = portainer.EndpointGroupID(*payload.GroupID) + groupID := portainer.EndpointGroupID(*payload.GroupID) + groupIDChanged = groupID != endpoint.GroupID + endpoint.GroupID = groupID } + tagsChanged := false if payload.TagIDs != nil { - endpoint.TagIDs = payload.TagIDs + payloadTagSet := portainer.TagSet(payload.TagIDs) + endpointTagSet := portainer.TagSet((endpoint.TagIDs)) + union := portainer.TagUnion(payloadTagSet, endpointTagSet) + intersection := portainer.TagIntersection(payloadTagSet, endpointTagSet) + tagsChanged = len(union) > len(intersection) + + if tagsChanged { + removeTags := portainer.TagDifference(endpointTagSet, payloadTagSet) + + for tagID := range removeTags { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + delete(tag.Endpoints, endpoint.ID) + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + + endpoint.TagIDs = payload.TagIDs + for _, tagID := range payload.TagIDs { + tag, err := handler.TagService.Tag(tagID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag inside the database", err} + } + + tag.Endpoints[endpoint.ID] = true + + err = handler.TagService.UpdateTag(tag.ID, tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} + } + } + } } updateAuthorizations := false @@ -184,5 +224,41 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } + if endpoint.Type == portainer.EdgeAgentEnvironment && (groupIDChanged || tagsChanged) { + relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation inside the database", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint group inside the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + edgeStackSet := map[portainer.EdgeStackID]bool{} + + endpointEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, edgeStackID := range endpointEdgeStacks { + edgeStackSet[edgeStackID] = true + } + + relation.EdgeStacks = edgeStackSet + + err = handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation 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 index 6281a4a98..bca5dea75 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -29,16 +29,19 @@ type Handler struct { *mux.Router authorizeEndpointManagement bool requestBouncer *security.RequestBouncer + AuthorizationService *portainer.AuthorizationService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService FileService portainer.FileService - ProxyManager *proxy.Manager - Snapshotter portainer.Snapshotter JobService portainer.JobService + ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SettingsService portainer.SettingsService - TagsService portainer.TagService - AuthorizationService *portainer.AuthorizationService + Snapshotter portainer.Snapshotter + TagService portainer.TagService } // NewHandler creates a handler to manage endpoint operations. @@ -71,6 +74,5 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) - return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 7bb72dbb9..8b167b12e 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -4,6 +4,10 @@ import ( "net/http" "strings" + "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgestacks" + "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/support" "github.com/portainer/portainer/api/http/handler/schedules" @@ -37,6 +41,10 @@ import ( type Handler struct { AuthHandler *auth.Handler DockerHubHandler *dockerhub.Handler + EdgeGroupsHandler *edgegroups.Handler + EdgeStacksHandler *edgestacks.Handler + EdgeTemplatesHandler *edgetemplates.Handler + EndpointEdgeHandler *endpointedge.Handler EndpointGroupHandler *endpointgroups.Handler EndpointHandler *endpoints.Handler EndpointProxyHandler *endpointproxy.Handler @@ -68,6 +76,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"): + http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_groups"): + http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/edge_templates"): + http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): @@ -78,6 +92,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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) + case strings.Contains(r.URL.Path, "/edge/"): + http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index afa8e85dd..e70125afe 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -16,6 +16,7 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` OAuthLoginURI string `json:"OAuthLoginURI"` } @@ -34,6 +35,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, + EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, ExternalTemplates: false, OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", settings.OAuthSettings.AuthorizationURI, diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 82e00861d..cef3bdf0f 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -24,6 +24,7 @@ type settingsUpdatePayload struct { SnapshotInterval *string TemplatesURL *string EdgeAgentCheckinInterval *int + EnableEdgeComputeFeatures *bool } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { @@ -109,6 +110,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures } + if payload.EnableEdgeComputeFeatures != nil { + settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures + } + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) if err != nil { diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index b5dac0274..21ca61acb 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -12,9 +12,12 @@ import ( // Handler is the HTTP handler used to handle tag operations. type Handler struct { *mux.Router - TagService portainer.TagService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService + TagService portainer.TagService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService } // NewHandler creates a handler to manage tag operations. diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go index e639cd39b..846d256ee 100644 --- a/api/http/handler/tags/tag_create.go +++ b/api/http/handler/tags/tag_create.go @@ -41,7 +41,9 @@ func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httpe } tag := &portainer.Tag{ - Name: payload.Name, + Name: payload.Name, + EndpointGroups: map[portainer.EndpointGroupID]bool{}, + Endpoints: map[portainer.EndpointID]bool{}, } err = handler.TagService.CreateTag(tag) diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index 2467a38fd..c2cafe43e 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -17,39 +17,82 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe } tagID := portainer.TagID(id) - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + tag, err := handler.TagService.Tag(tagID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a tag with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a tag with the specified identifier inside the database", err} } - for _, endpoint := range endpoints { + for endpointID := range tag.Endpoints { + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + } + tagIdx := findTagIndex(endpoint.TagIDs, tagID) if tagIdx != -1 { endpoint.TagIDs = removeElement(endpoint.TagIDs, tagIdx) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } } } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} - } + for endpointGroupID := range tag.EndpointGroups { + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpointGroupID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint group from the database", err} + } - for _, endpointGroup := range endpointGroups { tagIdx := findTagIndex(endpointGroup.TagIDs, tagID) if tagIdx != -1 { endpointGroup.TagIDs = removeElement(endpointGroup.TagIDs, tagIdx) - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} } } } - err = handler.TagService.DeleteTag(portainer.TagID(id)) + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} + } + + edgeStacks, err := handler.EdgeStackService.EdgeStacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} + } + + for _, endpoint := range endpoints { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && endpoint.Type == portainer.EdgeAgentEnvironment { + err = handler.updateEndpointRelations(endpoint, edgeGroups, edgeStacks) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint relations in the database", err} + } + } + } + + for idx := range edgeGroups { + edgeGroup := &edgeGroups[idx] + tagIdx := findTagIndex(edgeGroup.TagIDs, tagID) + if tagIdx != -1 { + edgeGroup.TagIDs = removeElement(edgeGroup.TagIDs, tagIdx) + err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} + } + } + } + + err = handler.TagService.DeleteTag(tagID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} } @@ -57,6 +100,27 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe return response.Empty(w) } +func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { + endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + endpointStacks := portainer.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + endpointRelation.EdgeStacks = stacksSet + + return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, endpointRelation) +} + func findTagIndex(tags []portainer.TagID, searchTagID portainer.TagID) int { for idx, tagID := range tags { if searchTagID == tagID { diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index d52c98562..5bcfe0c72 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -1,6 +1,8 @@ package security import ( + "errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" @@ -143,6 +145,24 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return nil } +// AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge endpoint +func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + if endpoint.Type != portainer.EdgeAgentEnvironment { + return errors.New("Invalid endpoint type") + } + + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + if edgeIdentifier == "" { + return errors.New("missing Edge identifier") + } + + if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { + return errors.New("invalid Edge identifier") + } + + return nil +} + func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Request, endpoint *portainer.Endpoint) error { tokenData, err := RetrieveTokenData(r) if err != nil { diff --git a/api/http/server.go b/api/http/server.go index eb4777071..f1c98ee5f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -3,6 +3,10 @@ package http import ( "time" + "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgestacks" + "github.com/portainer/portainer/api/http/handler/edgetemplates" + "github.com/portainer/portainer/api/http/handler/endpointedge" "github.com/portainer/portainer/api/http/handler/support" "github.com/portainer/portainer/api/http/handler/roles" @@ -41,45 +45,48 @@ import ( // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - AuthDisabled bool - EndpointManagement bool - Status *portainer.Status - ReverseTunnelService portainer.ReverseTunnelService - ExtensionManager portainer.ExtensionManager - ComposeStackManager portainer.ComposeStackManager - CryptoService portainer.CryptoService - SignatureService portainer.DigitalSignatureService - JobScheduler portainer.JobScheduler - Snapshotter portainer.Snapshotter - RoleService portainer.RoleService - DockerHubService portainer.DockerHubService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - FileService portainer.FileService - GitService portainer.GitService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - ExtensionService portainer.ExtensionService - RegistryService portainer.RegistryService - ResourceControlService portainer.ResourceControlService - ScheduleService portainer.ScheduleService - SettingsService portainer.SettingsService - StackService portainer.StackService - SwarmStackManager portainer.SwarmStackManager - TagService portainer.TagService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - TemplateService portainer.TemplateService - UserService portainer.UserService - WebhookService portainer.WebhookService - Handler *handler.Handler - SSL bool - SSLCert string - SSLKey string - DockerClientFactory *docker.ClientFactory - JobService portainer.JobService + BindAddress string + AssetsPath string + AuthDisabled bool + EndpointManagement bool + Status *portainer.Status + ReverseTunnelService portainer.ReverseTunnelService + ExtensionManager portainer.ExtensionManager + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + JobScheduler portainer.JobScheduler + Snapshotter portainer.Snapshotter + RoleService portainer.RoleService + DockerHubService portainer.DockerHubService + EdgeGroupService portainer.EdgeGroupService + EdgeStackService portainer.EdgeStackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + EndpointRelationService portainer.EndpointRelationService + FileService portainer.FileService + GitService portainer.GitService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + ExtensionService portainer.ExtensionService + RegistryService portainer.RegistryService + ResourceControlService portainer.ResourceControlService + ScheduleService portainer.ScheduleService + SettingsService portainer.SettingsService + StackService portainer.StackService + SwarmStackManager portainer.SwarmStackManager + TagService portainer.TagService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + TemplateService portainer.TemplateService + UserService portainer.UserService + WebhookService portainer.WebhookService + Handler *handler.Handler + SSL bool + SSLCert string + SSLKey string + DockerClientFactory *docker.ClientFactory + JobService portainer.JobService } // Start starts the HTTP server @@ -144,21 +151,54 @@ func (server *Server) Start() error { var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DockerHubService = server.DockerHubService + var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) + edgeGroupsHandler.EdgeGroupService = server.EdgeGroupService + edgeGroupsHandler.EdgeStackService = server.EdgeStackService + edgeGroupsHandler.EndpointService = server.EndpointService + edgeGroupsHandler.EndpointGroupService = server.EndpointGroupService + edgeGroupsHandler.EndpointRelationService = server.EndpointRelationService + edgeGroupsHandler.TagService = server.TagService + + var edgeStacksHandler = edgestacks.NewHandler(requestBouncer) + edgeStacksHandler.EdgeGroupService = server.EdgeGroupService + edgeStacksHandler.EdgeStackService = server.EdgeStackService + edgeStacksHandler.EndpointService = server.EndpointService + edgeStacksHandler.EndpointGroupService = server.EndpointGroupService + edgeStacksHandler.EndpointRelationService = server.EndpointRelationService + edgeStacksHandler.FileService = server.FileService + edgeStacksHandler.GitService = server.GitService + + var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) + edgeTemplatesHandler.SettingsService = server.SettingsService + var endpointHandler = endpoints.NewHandler(requestBouncer, server.EndpointManagement) + endpointHandler.AuthorizationService = authorizationService + endpointHandler.EdgeGroupService = server.EdgeGroupService + endpointHandler.EdgeStackService = server.EdgeStackService endpointHandler.EndpointService = server.EndpointService endpointHandler.EndpointGroupService = server.EndpointGroupService + endpointHandler.EndpointRelationService = server.EndpointRelationService endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager - endpointHandler.Snapshotter = server.Snapshotter endpointHandler.JobService = server.JobService + endpointHandler.ProxyManager = proxyManager endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.SettingsService = server.SettingsService - endpointHandler.AuthorizationService = authorizationService + endpointHandler.Snapshotter = server.Snapshotter + endpointHandler.TagService = server.TagService + + var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) + endpointEdgeHandler.EdgeStackService = server.EdgeStackService + endpointEdgeHandler.EndpointService = server.EndpointService + endpointEdgeHandler.FileService = server.FileService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) - endpointGroupHandler.EndpointGroupService = server.EndpointGroupService - endpointGroupHandler.EndpointService = server.EndpointService endpointGroupHandler.AuthorizationService = authorizationService + endpointGroupHandler.EdgeGroupService = server.EdgeGroupService + endpointGroupHandler.EdgeStackService = server.EdgeStackService + endpointGroupHandler.EndpointService = server.EndpointService + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointRelationService = server.EndpointRelationService + endpointGroupHandler.TagService = server.TagService var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.EndpointService = server.EndpointService @@ -221,9 +261,12 @@ func (server *Server) Start() error { stackHandler.ExtensionService = server.ExtensionService var tagHandler = tags.NewHandler(requestBouncer) - tagHandler.TagService = server.TagService + tagHandler.EdgeGroupService = server.EdgeGroupService + tagHandler.EdgeStackService = server.EdgeStackService tagHandler.EndpointService = server.EndpointService tagHandler.EndpointGroupService = server.EndpointGroupService + tagHandler.EndpointRelationService = server.EndpointRelationService + tagHandler.TagService = server.TagService var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService @@ -268,8 +311,12 @@ func (server *Server) Start() error { RoleHandler: roleHandler, AuthHandler: authHandler, DockerHubHandler: dockerHubHandler, + EdgeGroupsHandler: edgeGroupsHandler, + EdgeStacksHandler: edgeStacksHandler, + EdgeTemplatesHandler: edgeTemplatesHandler, EndpointGroupHandler: endpointGroupHandler, EndpointHandler: endpointHandler, + EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, MOTDHandler: motdHandler, diff --git a/api/portainer.go b/api/portainer.go index 5e69a2eb5..c6a469d84 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -84,6 +84,19 @@ type ( Password string `json:"Password,omitempty"` } + // EdgeGroup represents an Edge group + EdgeGroup struct { + ID EdgeGroupID `json:"Id"` + Name string `json:"Name"` + Dynamic bool `json:"Dynamic"` + TagIDs []TagID `json:"TagIds"` + Endpoints []EndpointID `json:"Endpoints"` + PartialMatch bool `json:"PartialMatch"` + } + + // EdgeGroupID represents an Edge group identifier + EdgeGroupID int + // EdgeSchedule represents a scheduled job that can run on Edge environments. EdgeSchedule struct { ID ScheduleID `json:"Id"` @@ -93,6 +106,32 @@ type ( Endpoints []EndpointID `json:"Endpoints"` } + //EdgeStack represents an edge stack + EdgeStack struct { + ID EdgeStackID `json:"Id"` + Name string `json:"Name"` + Status map[EndpointID]EdgeStackStatus `json:"Status"` + CreationDate int64 `json:"CreationDate"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + Version int `json:"Version"` + Prune bool `json:"Prune"` + } + + //EdgeStackID represents an edge stack id + EdgeStackID int + + //EdgeStackStatus represents an edge stack status + EdgeStackStatus struct { + Type EdgeStackStatusType `json:"Type"` + Error string `json:"Error"` + EndpointID EndpointID `json:"EndpointID"` + } + + //EdgeStackStatusType represents an edge stack status type + EdgeStackStatusType int + // Endpoint represents a Docker endpoint with all the info required // to connect to it Endpoint struct { @@ -176,6 +215,12 @@ type ( // EndpointType represents the type of an endpoint EndpointType int + // EndpointRelation represnts a endpoint relation object + EndpointRelation struct { + EndpointID EndpointID + EdgeStacks map[EdgeStackID]bool + } + // Extension represents a Portainer extension Extension struct { ID ExtensionID `json:"Id"` @@ -387,6 +432,7 @@ type ( TemplatesURL string `json:"TemplatesURL"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` // Deprecated fields DisplayDonationHeader bool @@ -454,8 +500,10 @@ type ( // Tag represents a tag that can be associated to a resource Tag struct { - ID TagID - Name string `json:"Name"` + ID TagID + Name string `json:"Name"` + Endpoints map[EndpointID]bool `json:"Endpoints"` + EndpointGroups map[EndpointGroupID]bool `json:"EndpointGroups"` } // TagID represents a tag identifier @@ -505,6 +553,9 @@ type ( // Mandatory stack fields Repository TemplateRepository `json:"repository"` + // Mandatory edge stack fields + StackFile string `json:"stackFile"` + // Optional stack/container fields Name string `json:"name,omitempty"` Logo string `json:"logo,omitempty"` @@ -685,6 +736,14 @@ type ( DeleteEndpointGroup(ID EndpointGroupID) error } + // EndpointRelationService represents a service for managing endpoint relations data + EndpointRelationService interface { + EndpointRelation(EndpointID EndpointID) (*EndpointRelation, error) + CreateEndpointRelation(endpointRelation *EndpointRelation) error + UpdateEndpointRelation(EndpointID EndpointID, endpointRelation *EndpointRelation) error + DeleteEndpointRelation(EndpointID EndpointID) error + } + // ExtensionManager represents a service used to manage extensions ExtensionManager interface { FetchExtensionDefinitions() ([]Extension, error) @@ -714,6 +773,8 @@ type ( DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + GetEdgeStackProjectPath(edgeStackIdentifier string) string + StoreEdgeStackFileFromBytes(edgeStackIdentifier, fileName string, data []byte) (string, error) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error @@ -855,6 +916,7 @@ type ( Tags() ([]Tag, error) Tag(ID TagID) (*Tag, error) CreateTag(tag *Tag) error + UpdateTag(ID TagID, tag *Tag) error DeleteTag(ID TagID) error } @@ -922,6 +984,25 @@ type ( WebhookByToken(token string) (*Webhook, error) DeleteWebhook(serviceID WebhookID) error } + + // EdgeGroupService represents a service to manage Edge groups + EdgeGroupService interface { + EdgeGroups() ([]EdgeGroup, error) + EdgeGroup(ID EdgeGroupID) (*EdgeGroup, error) + CreateEdgeGroup(group *EdgeGroup) error + UpdateEdgeGroup(ID EdgeGroupID, group *EdgeGroup) error + DeleteEdgeGroup(ID EdgeGroupID) error + } + + // EdgeStackService represents a service to manage Edge stacks + EdgeStackService interface { + EdgeStacks() ([]EdgeStack, error) + EdgeStack(ID EdgeStackID) (*EdgeStack, error) + CreateEdgeStack(edgeStack *EdgeStack) error + UpdateEdgeStack(ID EdgeStackID, edgeStack *EdgeStack) error + DeleteEdgeStack(ID EdgeStackID) error + GetNextIdentifier() int + } ) const ( @@ -958,6 +1039,8 @@ const ( DefaultEdgeAgentCheckinIntervalInSeconds = 5 // LocalExtensionManifestFile represents the name of the local manifest file for extensions LocalExtensionManifestFile = "/extensions.json" + // EdgeTemplatesURL represents the URL used to retrieve Edge templates + EdgeTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-1.20.0.json" ) const ( @@ -970,6 +1053,16 @@ const ( AuthenticationOAuth ) +const ( + _ EdgeStackStatusType = iota + //StatusOk represents a successfully deployed edge stack + StatusOk + //StatusError represents an edge endpoint which failed to deploy its edge stack + StatusError + //StatusAcknowledged represents an acknowledged edge stack + StatusAcknowledged +) + const ( _ EndpointExtensionType = iota // StoridgeEndpointExtension represents the Storidge extension @@ -1078,6 +1171,8 @@ const ( SwarmStackTemplate // ComposeStackTemplate represents a template used to deploy a Compose stack ComposeStackTemplate + // EdgeStackTemplate represents a template used to deploy an Edge stack + EdgeStackTemplate ) const ( diff --git a/api/tag.go b/api/tag.go new file mode 100644 index 000000000..f93c6b547 --- /dev/null +++ b/api/tag.go @@ -0,0 +1,71 @@ +package portainer + +type tagSet map[TagID]bool + +// TagSet converts an array of ids to a set +func TagSet(tagIDs []TagID) tagSet { + set := map[TagID]bool{} + for _, tagID := range tagIDs { + set[tagID] = true + } + return set +} + +// TagIntersection returns a set intersection of the provided sets +func TagIntersection(sets ...tagSet) tagSet { + intersection := tagSet{} + if len(sets) == 0 { + return intersection + } + setA := sets[0] + for tag := range setA { + inAll := true + for _, setB := range sets { + if !setB[tag] { + inAll = false + break + } + } + + if inAll { + intersection[tag] = true + } + } + + return intersection +} + +// TagUnion returns a set union of provided sets +func TagUnion(sets ...tagSet) tagSet { + union := tagSet{} + for _, set := range sets { + for tag := range set { + union[tag] = true + } + } + return union +} + +// TagContains return true if setA contains setB +func TagContains(setA tagSet, setB tagSet) bool { + containedTags := 0 + for tag := range setB { + if setA[tag] { + containedTags++ + } + } + return containedTags == len(setA) +} + +// TagDifference returns the set difference tagsA - tagsB +func TagDifference(setA tagSet, setB tagSet) tagSet { + set := tagSet{} + + for tag := range setA { + if !setB[tag] { + set[tag] = true + } + } + + return set +} diff --git a/app/__module.js b/app/__module.js index e5db84c05..53c901aab 100644 --- a/app/__module.js +++ b/app/__module.js @@ -4,6 +4,7 @@ import angular from 'angular'; import './agent/_module'; import './azure/_module'; import './docker/__module'; +import './edge/__module'; import './portainer/__module'; angular.module('portainer', [ @@ -29,6 +30,7 @@ angular.module('portainer', [ 'portainer.agent', 'portainer.azure', 'portainer.docker', + 'portainer.edge', 'portainer.extensions', 'portainer.integrations', 'rzModule', diff --git a/app/constants.js b/app/constants.js index 052c7d2ed..e615db918 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,6 +2,9 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') + .constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks') + .constant('API_ENDPOINT_EDGE_TEMPLATES', 'api/edge_templates') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') diff --git a/app/edge/__module.js b/app/edge/__module.js new file mode 100644 index 000000000..dc813eba2 --- /dev/null +++ b/app/edge/__module.js @@ -0,0 +1,80 @@ +import angular from 'angular'; + +angular.module('portainer.edge', []).config(function config($stateRegistryProvider) { + const edge = { + name: 'edge', + url: '/edge', + parent: 'root', + abstract: true, + }; + + const groups = { + name: 'edge.groups', + url: '/groups', + views: { + 'content@': { + component: 'edgeGroupsView', + }, + }, + }; + + const groupsNew = { + name: 'edge.groups.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeGroupView', + }, + }, + }; + + const groupsEdit = { + name: 'edge.groups.edit', + url: '/:groupId', + views: { + 'content@': { + component: 'editEdgeGroupView', + }, + }, + }; + + const stacks = { + name: 'edge.stacks', + url: '/stacks', + views: { + 'content@': { + component: 'edgeStacksView', + }, + }, + }; + + const stacksNew = { + name: 'edge.stacks.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeStackView', + }, + }, + }; + + const stacksEdit = { + name: 'edge.stacks.edit', + url: '/:stackId', + views: { + 'content@': { + component: 'editEdgeStackView', + }, + }, + }; + + $stateRegistryProvider.register(edge); + + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(groupsNew); + $stateRegistryProvider.register(groupsEdit); + + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stacksNew); + $stateRegistryProvider.register(stacksEdit); +}); diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html new file mode 100644 index 000000000..d19f4b8bc --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html @@ -0,0 +1,82 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Group + + + +
+ {{ item.Name }} + {{ item.GroupName }}
Loading...
No endpoint available.
+
+ +
+
+
diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js new file mode 100644 index 000000000..93d4b748f --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.edge').component('associatedEndpointsDatatable', { + templateUrl: './associatedEndpointsDatatable.html', + controller: 'AssociatedEndpointsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + retrievePage: '<', + updateKey: '<', + }, +}); diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js new file mode 100644 index 000000000..8ba5bdc19 --- /dev/null +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatableController.js @@ -0,0 +1,103 @@ +import angular from 'angular'; + +class AssociatedEndpointsDatatableController { + constructor($scope, $controller, DatatableService, PaginationService) { + this.extendGenericController($controller, $scope); + this.DatatableService = DatatableService; + this.PaginationService = PaginationService; + + this.state = Object.assign(this.state, { + orderBy: this.orderBy, + loading: true, + filteredDataSet: [], + totalFilteredDataset: 0, + pageNumber: 1, + }); + + this.onPageChange = this.onPageChange.bind(this); + this.paginationChanged = this.paginationChanged.bind(this); + } + + extendGenericController($controller, $scope) { + // extending the controller overrides the current controller functions + const $onInit = this.$onInit.bind(this); + const changePaginationLimit = this.changePaginationLimit.bind(this); + const onTextFilterChange = this.onTextFilterChange.bind(this); + angular.extend(this, $controller('GenericDatatableController', { $scope })); + this.$onInit = $onInit; + this.changePaginationLimit = changePaginationLimit; + this.onTextFilterChange = onTextFilterChange; + } + + $onInit() { + this.setDefaults(); + this.prepareTableFromDataset(); + + var storedOrder = this.DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = this.DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + this.paginationChanged(); + } + + $onChanges({ updateKey }) { + if (updateKey.currentValue && !updateKey.isFirstChange()) { + this.paginationChanged(); + } + } + + onPageChange(newPageNumber) { + this.state.pageNumber = newPageNumber; + this.paginationChanged(); + } + + /** + * Overridden + */ + changePaginationLimit() { + this.PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + this.paginationChanged(); + } + + /** + * Overridden + */ + onTextFilterChange() { + var filterValue = this.state.textFilter; + this.DatatableService.setDataTableTextFilters(this.tableKey, filterValue); + this.paginationChanged(); + } + + paginationChanged() { + this.state.loading = true; + this.state.filteredDataSet = []; + const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1; + this.retrievePage(start, this.state.paginatedItemLimit, this.state.textFilter) + .then((data) => { + this.state.filteredDataSet = data.endpoints; + this.state.totalFilteredDataSet = data.totalCount; + }) + .finally(() => { + this.state.loading = false; + }); + } +} + +angular.module('portainer.edge').controller('AssociatedEndpointsDatatableController', AssociatedEndpointsDatatableController); +export default AssociatedEndpointsDatatableController; diff --git a/app/edge/components/edge-groups-selector/edge-groups-selector.html b/app/edge/components/edge-groups-selector/edge-groups-selector.html new file mode 100644 index 000000000..2badca9a2 --- /dev/null +++ b/app/edge/components/edge-groups-selector/edge-groups-selector.html @@ -0,0 +1,18 @@ + + + + {{ $item.Name }} + + + + + {{ item.Name }} + + + diff --git a/app/edge/components/edge-groups-selector/edge-groups-selector.js b/app/edge/components/edge-groups-selector/edge-groups-selector.js new file mode 100644 index 000000000..3322780b8 --- /dev/null +++ b/app/edge/components/edge-groups-selector/edge-groups-selector.js @@ -0,0 +1,7 @@ +angular.module('portainer.edge').component('edgeGroupsSelector', { + templateUrl: './edge-groups-selector.html', + bindings: { + model: '=', + items: '<' + } +}); diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html new file mode 100644 index 000000000..69bb34b98 --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html @@ -0,0 +1,87 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Status + + + + + + Error + + + +
{{ item.Name }}{{ $ctrl.statusMap[item.Status.Type] || 'Pending' }}{{ item.Status.Error ? item.Status.Error : '-' }}
Loading...
No endpoint available.
+
+ +
+
+
diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js new file mode 100644 index 000000000..85783034d --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.edge').component('edgeStackEndpointsDatatable', { + templateUrl: './edgeStackEndpointsDatatable.html', + controller: 'EdgeStackEndpointsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + retrievePage: '<', + }, +}); diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js new file mode 100644 index 000000000..dec0c08ed --- /dev/null +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js @@ -0,0 +1,111 @@ +import angular from 'angular'; + +class EdgeStackEndpointsDatatableController { + constructor($async, $scope, $controller, DatatableService, PaginationService, Notifications) { + this.extendGenericController($controller, $scope); + this.DatatableService = DatatableService; + this.PaginationService = PaginationService; + this.Notifications = Notifications; + this.$async = $async; + + this.state = Object.assign(this.state, { + orderBy: this.orderBy, + loading: true, + filteredDataSet: [], + totalFilteredDataset: 0, + pageNumber: 1, + }); + + this.onPageChange = this.onPageChange.bind(this); + this.paginationChanged = this.paginationChanged.bind(this); + this.paginationChangedAsync = this.paginationChangedAsync.bind(this); + + this.statusMap = { + 1: 'OK', + 2: 'Error', + 3: 'Acknowledged', + }; + } + + extendGenericController($controller, $scope) { + // extending the controller overrides the current controller functions + const $onInit = this.$onInit.bind(this); + const changePaginationLimit = this.changePaginationLimit.bind(this); + const onTextFilterChange = this.onTextFilterChange.bind(this); + angular.extend(this, $controller('GenericDatatableController', { $scope })); + this.$onInit = $onInit; + this.changePaginationLimit = changePaginationLimit; + this.onTextFilterChange = onTextFilterChange; + } + + $onInit() { + this.setDefaults(); + this.prepareTableFromDataset(); + + var storedOrder = this.DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = this.DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + this.paginationChanged(); + } + + onPageChange(newPageNumber) { + this.state.pageNumber = newPageNumber; + this.paginationChanged(); + } + + /** + * Overridden + */ + changePaginationLimit() { + this.PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + this.paginationChanged(); + } + + /** + * Overridden + */ + onTextFilterChange() { + var filterValue = this.state.textFilter; + this.DatatableService.setDataTableTextFilters(this.tableKey, filterValue); + this.paginationChanged(); + } + + paginationChanged() { + this.$async(this.paginationChangedAsync); + } + + async paginationChangedAsync() { + this.state.loading = true; + this.state.filteredDataSet = []; + const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1; + try { + const { endpoints, totalCount } = await this.retrievePage(start, this.state.paginatedItemLimit, this.state.textFilter); + this.state.filteredDataSet = endpoints; + this.state.totalFilteredDataSet = totalCount; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + } finally { + this.state.loading = false; + } + } +} + +angular.module('portainer.edge').controller('EdgeStackEndpointsDatatableController', EdgeStackEndpointsDatatableController); +export default EdgeStackEndpointsDatatableController; diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.css b/app/edge/components/edge-stack-status/edgeStackStatus.css new file mode 100644 index 000000000..95778ac02 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.css @@ -0,0 +1,25 @@ +.status:not(:last-child) { + margin-right: 1em; +} + +.status .icon { + padding: 0 !important; + margin-right: 1ch; + border-radius: 50%; + background-color: grey; + height: 10px; + width: 10px; + display: inline-block; +} + +.status .error { + background-color: #ae2323; +} + +.status .acknowledged { + background-color: #337ab7; +} + +.status .ok { + background-color: #23ae89; +} diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.html b/app/edge/components/edge-stack-status/edgeStackStatus.html new file mode 100644 index 000000000..5ffda22b5 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.html @@ -0,0 +1,3 @@ +{{ $ctrl.status.acknowledged || 0 }} +{{ $ctrl.status.ok || 0 }} +{{ $ctrl.status.error || 0 }} diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.js b/app/edge/components/edge-stack-status/edgeStackStatus.js new file mode 100644 index 000000000..a0ac9be38 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +import './edgeStackStatus.css'; + +angular.module('portainer.edge').component('edgeStackStatus', { + templateUrl: './edgeStackStatus.html', + controller: 'EdgeStackStatusController', + bindings: { + stackStatus: '<', + }, +}); diff --git a/app/edge/components/edge-stack-status/edgeStackStatusController.js b/app/edge/components/edge-stack-status/edgeStackStatusController.js new file mode 100644 index 000000000..7ce46ebb8 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatusController.js @@ -0,0 +1,25 @@ +import angular from 'angular'; + +const statusMap = { + 1: 'ok', + 2: 'error', + 3: 'acknowledged', +}; + +class EdgeStackStatusController { + $onChanges({ stackStatus }) { + if (!stackStatus || !stackStatus.currentValue) { + return; + } + const aggregateStatus = { ok: 0, error: 0, acknowledged: 0 }; + for (let endpointId in stackStatus.currentValue) { + const endpoint = stackStatus.currentValue[endpointId]; + const endpointStatusKey = statusMap[endpoint.Type]; + aggregateStatus[endpointStatusKey]++; + } + this.status = aggregateStatus; + } +} + +angular.module('portainer.edge').controller('EdgeStackStatusController', EdgeStackStatusController); +export default EdgeStackStatusController; diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html new file mode 100644 index 000000000..a5e1dbc66 --- /dev/null +++ b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html @@ -0,0 +1,145 @@ +
+ + +
+
+ + Edge Stacks +
+
+ + Settings + + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + Status + + + Creation Date + + + +
+ + + + + + {{ item.Name }} + + {{ item.CreationDate | getisodatefromtimestamp }}
Loading...
+ No stack available. +
+
+ +
+
+
diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js new file mode 100644 index 000000000..6d05005fe --- /dev/null +++ b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.edge').component('edgeStacksDatatable', { + templateUrl: './edgeStacksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<', + refreshCallback: '<', + }, +}); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html new file mode 100644 index 000000000..e21b87c0e --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -0,0 +1,74 @@ +
+
+ Edge Groups +
+
+
+ +
+
+ + +
+ Web editor +
+
+ + You can get more information about Compose file format in the + + official documentation + + . + +
+
+
+ +
+
+ + +
+ Options +
+
+
+ + +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js new file mode 100644 index 000000000..479a6876e --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.js @@ -0,0 +1,10 @@ +angular.module('portainer.edge').component('editEdgeStackForm', { + templateUrl: './editEdgeStackForm.html', + controller: 'EditEdgeStackFormController', + bindings: { + model: '<', + actionInProgress: '<', + submitAction: '<', + edgeGroups: '<', + }, +}); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js new file mode 100644 index 000000000..0c836db04 --- /dev/null +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js @@ -0,0 +1,14 @@ +import angular from 'angular'; + +class EditEdgeStackFormController { + constructor() { + this.editorUpdate = this.editorUpdate.bind(this); + } + + editorUpdate(cm) { + this.model.StackFileContent = cm.getValue(); + } +} + +angular.module('portainer.edge').controller('EditEdgeStackFormController', EditEdgeStackFormController); +export default EditEdgeStackFormController; diff --git a/app/edge/components/group-form/groupForm.html b/app/edge/components/group-form/groupForm.html new file mode 100644 index 000000000..aedf32285 --- /dev/null +++ b/app/edge/components/group-form/groupForm.html @@ -0,0 +1,189 @@ +
+
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ +
+ Group type +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ Associated endpoints +
+
+
+ You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click on any endpoint entry to move it from one table + to the other. +
+
+ +
+
Available endpoints
+
+ +
+
+ + +
+
Associated endpoints
+
+ +
+
+ +
+
+
+
+
No Edge endpoints available. Head over the Endpoints view to add endpoints.
+
+
+ + + +
+
+ Tags +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+ No tags available. Head over to the Tags view to add tags +
+
+
+ Associated endpoints by tags +
+
+ +
+
+ + + +
+ Actions +
+
+
+ +
+
+
diff --git a/app/edge/components/group-form/groupForm.js b/app/edge/components/group-form/groupForm.js new file mode 100644 index 000000000..2b5f66091 --- /dev/null +++ b/app/edge/components/group-form/groupForm.js @@ -0,0 +1,14 @@ +angular.module('portainer.edge').component('edgeGroupForm', { + templateUrl: './groupForm.html', + controller: 'EdgeGroupFormController', + bindings: { + model: '<', + groups: '<', + tags: '<', + formActionLabel: '@', + formAction: '<', + actionInProgress: '<', + loaded: '<', + pageType: '@', + }, +}); diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js new file mode 100644 index 000000000..413a988e9 --- /dev/null +++ b/app/edge/components/group-form/groupFormController.js @@ -0,0 +1,94 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeGroupFormController { + /* @ngInject */ + constructor(EndpointService, $async, $scope) { + this.EndpointService = EndpointService; + this.$async = $async; + + this.state = { + available: { + limit: '10', + filter: '', + pageNumber: 1, + totalCount: 0, + }, + associated: { + limit: '10', + filter: '', + pageNumber: 1, + totalCount: 0, + }, + }; + + this.endpoints = { + associated: [], + available: null, + }; + + this.associateEndpoint = this.associateEndpoint.bind(this); + this.dissociateEndpoint = this.dissociateEndpoint.bind(this); + this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this); + this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this); + + $scope.$watch( + () => this.model, + () => { + this.getPaginatedEndpoints(this.pageType, 'associated'); + }, + true + ); + } + + associateEndpoint(endpoint) { + if (!_.includes(this.model.Endpoints, endpoint.Id)) { + this.endpoints.associated.push(endpoint); + this.model.Endpoints.push(endpoint.Id); + _.remove(this.endpoints.available, { Id: endpoint.Id }); + } + } + + dissociateEndpoint(endpoint) { + _.remove(this.endpoints.associated, { Id: endpoint.Id }); + _.remove(this.model.Endpoints, (id) => id === endpoint.Id); + this.endpoints.available.push(endpoint); + } + + getPaginatedEndpoints(pageType, tableType) { + return this.$async(this.getPaginatedEndpointsAsync, pageType, tableType); + } + + async getPaginatedEndpointsAsync(pageType, tableType) { + const { pageNumber, limit, search } = this.state[tableType]; + const start = (pageNumber - 1) * limit + 1; + const query = { search, type: 4 }; + if (tableType === 'associated') { + if (this.model.Dynamic) { + query.tagIds = this.model.TagIds; + query.tagsPartialMatch = this.model.PartialMatch; + } else { + query.endpointIds = this.model.Endpoints; + } + } + const response = await this.fetchEndpoints(start, limit, query); + const totalCount = parseInt(response.totalCount, 10); + this.endpoints[tableType] = response.value; + this.state[tableType].totalCount = totalCount; + + if (tableType === 'available') { + this.noEndpoints = totalCount === 0; + this.endpoints[tableType] = _.filter(response.value, (endpoint) => !_.includes(this.model.Endpoints, endpoint.Id)); + } + } + + fetchEndpoints(start, limit, query) { + if (query.tagIds && !query.tagIds.length) { + return { value: [], totalCount: 0 }; + } + return this.EndpointService.endpoints(start, limit, query); + } +} + +angular.module('portainer.edge').controller('EdgeGroupFormController', EdgeGroupFormController); +export default EdgeGroupFormController; diff --git a/app/edge/components/groups-datatable/groupsDatatable.html b/app/edge/components/groups-datatable/groupsDatatable.html new file mode 100644 index 000000000..28115932d --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatable.html @@ -0,0 +1,102 @@ +
+ + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Endpoints Count + + + + + + Group Type + + + +
+ + + + + {{ item.Name }} + in use + {{ item.Endpoints.length }}{{ item.Dynamic ? 'Dynamic' : 'Static' }}
Loading...
+ No Edge group available. +
+
+ +
+
+
diff --git a/app/edge/components/groups-datatable/groupsDatatable.js b/app/edge/components/groups-datatable/groupsDatatable.js new file mode 100644 index 000000000..3749e8eb6 --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatable.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.edge').component('edgeGroupsDatatable', { + templateUrl: './groupsDatatable.html', + controller: 'EdgeGroupsDatatableController', + bindings: { + dataset: '<', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + removeAction: '<', + updateAction: '<', + reverseOrder: '<', + }, +}); diff --git a/app/edge/components/groups-datatable/groupsDatatableController.js b/app/edge/components/groups-datatable/groupsDatatableController.js new file mode 100644 index 000000000..afe9468d7 --- /dev/null +++ b/app/edge/components/groups-datatable/groupsDatatableController.js @@ -0,0 +1,19 @@ +import angular from 'angular'; + +class EdgeGroupsDatatableController { + constructor($scope, $controller) { + const allowSelection = this.allowSelection; + angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + this.allowSelection = allowSelection.bind(this); + } + + /** + * Override this method to allow/deny selection + */ + allowSelection(item) { + return !item.HasEdgeStack; + } +} + +angular.module('portainer.edge').controller('EdgeGroupsDatatableController', EdgeGroupsDatatableController); +export default EdgeGroupsDatatableController; diff --git a/app/edge/rest/edge-groups.js b/app/edge/rest/edge-groups.js new file mode 100644 index 000000000..676b10281 --- /dev/null +++ b/app/edge/rest/edge-groups.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroups', function EdgeGroupsFactory($resource, API_ENDPOINT_EDGE_GROUPS) { + return $resource( + API_ENDPOINT_EDGE_GROUPS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@Id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); +}); diff --git a/app/edge/rest/edge-stacks.js b/app/edge/rest/edge-stacks.js new file mode 100644 index 000000000..5f5345061 --- /dev/null +++ b/app/edge/rest/edge-stacks.js @@ -0,0 +1,16 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStacks', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_STACKS) { + return $resource( + API_ENDPOINT_EDGE_STACKS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + file: { method: 'GET', params: { id: '@id', action: 'file' } }, + } + ); +}); diff --git a/app/edge/rest/edge-templates.js b/app/edge/rest/edge-templates.js new file mode 100644 index 000000000..e5aa85e2f --- /dev/null +++ b/app/edge/rest/edge-templates.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeTemplates', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_TEMPLATES) { + return $resource( + API_ENDPOINT_EDGE_TEMPLATES, + {}, + { + query: { method: 'GET', isArray: true }, + } + ); +}); diff --git a/app/edge/services/edge-group.js b/app/edge/services/edge-group.js new file mode 100644 index 000000000..48a27f377 --- /dev/null +++ b/app/edge/services/edge-group.js @@ -0,0 +1,27 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroupService', function EdgeGroupServiceFactory(EdgeGroups) { + var service = {}; + + service.group = function group(groupId) { + return EdgeGroups.get({ id: groupId }).$promise; + }; + + service.groups = function groups() { + return EdgeGroups.query({}).$promise; + }; + + service.remove = function remove(groupId) { + return EdgeGroups.remove({ id: groupId }).$promise; + }; + + service.create = function create(group) { + return EdgeGroups.create(group).$promise; + }; + + service.update = function update(group) { + return EdgeGroups.update(group).$promise; + }; + + return service; +}); diff --git a/app/edge/services/edge-stack.js b/app/edge/services/edge-stack.js new file mode 100644 index 000000000..9ec0f20e1 --- /dev/null +++ b/app/edge/services/edge-stack.js @@ -0,0 +1,75 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackServiceFactory(EdgeStacks, FileUploadService) { + var service = {}; + + service.stack = function stack(id) { + return EdgeStacks.get({ id }).$promise; + }; + + service.stacks = function stacks() { + return EdgeStacks.query({}).$promise; + }; + + service.remove = function remove(id) { + return EdgeStacks.remove({ id }).$promise; + }; + + service.stackFile = async function stackFile(id) { + try { + const { StackFileContent } = await EdgeStacks.file({ id }).$promise; + return StackFileContent; + } catch (err) { + throw { msg: 'Unable to retrieve stack content', err }; + } + }; + + service.updateStack = async function updateStack(id, stack) { + return EdgeStacks.update({ id }, stack); + }; + + service.createStackFromFileContent = async function createStackFromFileContent(name, stackFileContent, edgeGroups) { + var payload = { + Name: name, + StackFileContent: stackFileContent, + EdgeGroups: edgeGroups, + }; + try { + return await EdgeStacks.create({ method: 'string' }, payload).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromFileUpload = async function createStackFromFileUpload(name, stackFile, edgeGroups) { + try { + return await FileUploadService.createEdgeStack(name, stackFile, edgeGroups); + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromGitRepository = async function createStackFromGitRepository(name, repositoryOptions, edgeGroups) { + var payload = { + Name: name, + RepositoryURL: repositoryOptions.RepositoryURL, + RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + EdgeGroups: edgeGroups, + }; + try { + return await EdgeStacks.create({ method: 'repository' }, payload).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.update = function update(stack) { + return EdgeStacks.update(stack).$promise; + }; + + return service; +}); diff --git a/app/edge/services/edge-template.js b/app/edge/services/edge-template.js new file mode 100644 index 000000000..c8ce4a5ab --- /dev/null +++ b/app/edge/services/edge-template.js @@ -0,0 +1,23 @@ +import angular from 'angular'; + +class EdgeTemplateService { + /* @ngInject */ + constructor(EdgeTemplates) { + this.EdgeTemplates = EdgeTemplates; + } + + edgeTemplates() { + return this.EdgeTemplates.query().$promise; + } + + async edgeTemplate(template) { + const response = await fetch(template.stackFile); + if (!response.ok) { + throw new Error(response.statusText); + } + + return response.text(); + } +} + +angular.module('portainer.edge').service('EdgeTemplateService', EdgeTemplateService); diff --git a/app/edge/views/edge-stacks/create/createEdgeStackView.html b/app/edge/views/edge-stacks/create/createEdgeStackView.html new file mode 100644 index 000000000..d366039ac --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackView.html @@ -0,0 +1,291 @@ + + + Edge Stacks > Create Edge stack + + +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + This stack will be deployed using the equivalent of the + docker stack deploy command. + +
+
+ Edge Groups +
+
+
+ +
+
+ No Edge groups are available. Head over the Edge groups view to create one. +
+
+ +
+ Build method +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+ + You can get more information about Compose file format in the + + official documentation + + . + +
+
+
+ +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a Compose file from your computer. + +
+
+
+ + + {{ $ctrl.formValues.StackFile.name }} + + +
+
+
+ + +
+
+ Git repository +
+
+ + You can use the URL of a git repository. + +
+
+ +
+ +
+
+
+ + Specify a reference of the repository using the following syntax: branches with + refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the + master branch. + +
+
+ +
+ +
+
+
+ + Indicate the path to the Compose file from the root of your repository. + +
+
+ +
+ +
+
+
+
+ + +
+
+
+ + If your git account has 2FA enabled, you may receive an + authentication required error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password. + +
+
+ +
+ +
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+ +
+
+ Information +
+
+
+
+
+
+
+ + +
+
+ Web editor +
+
+
+ +
+
+
+
+ + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ +
+
+
+
+
diff --git a/app/edge/views/edge-stacks/create/createEdgeStackView.js b/app/edge/views/edge-stacks/create/createEdgeStackView.js new file mode 100644 index 000000000..5b5403337 --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('createEdgeStackView', { + templateUrl: './createEdgeStackView.html', + controller: 'CreateEdgeStackViewController', +}); diff --git a/app/edge/views/edge-stacks/create/createEdgeStackViewController.js b/app/edge/views/edge-stacks/create/createEdgeStackViewController.js new file mode 100644 index 000000000..aff2e517d --- /dev/null +++ b/app/edge/views/edge-stacks/create/createEdgeStackViewController.js @@ -0,0 +1,156 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class CreateEdgeStackViewController { + constructor($state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) { + Object.assign(this, { $state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async }); + + this.formValues = { + Name: '', + StackFileContent: '', + StackFile: null, + RepositoryURL: '', + RepositoryReferenceName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + Env: [], + ComposeFilePathInRepository: 'docker-compose.yml', + Groups: [], + }; + + this.state = { + Method: 'editor', + formValidationError: '', + actionInProgress: false, + StackType: null, + }; + + this.edgeGroups = null; + + this.createStack = this.createStack.bind(this); + this.createStackAsync = this.createStackAsync.bind(this); + this.validateForm = this.validateForm.bind(this); + this.createStackByMethod = this.createStackByMethod.bind(this); + this.createStackFromFileContent = this.createStackFromFileContent.bind(this); + this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this); + this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this); + this.editorUpdate = this.editorUpdate.bind(this); + this.onChangeTemplate = this.onChangeTemplate.bind(this); + this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this); + this.onChangeMethod = this.onChangeMethod.bind(this); + } + + async $onInit() { + try { + this.edgeGroups = await this.EdgeGroupService.groups(); + this.noGroups = this.edgeGroups.length === 0; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); + } + + try { + const templates = await this.EdgeTemplateService.edgeTemplates(); + this.templates = _.map(templates, (template) => ({ ...template, label: `${template.title} - ${template.description}` })); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Templates'); + } + } + + createStack() { + return this.$async(this.createStackAsync); + } + + onChangeMethod() { + this.formValues.StackFileContent = ''; + this.selectedTemplate = null; + } + + onChangeTemplate(template) { + return this.$async(this.onChangeTemplateAsync, template); + } + + async onChangeTemplateAsync(template) { + this.formValues.StackFileContent = ''; + try { + const fileContent = await this.EdgeTemplateService.edgeTemplate(template); + this.formValues.StackFileContent = fileContent; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Template'); + } + } + + async createStackAsync() { + const name = this.formValues.Name; + let method = this.state.Method; + + if (method === 'template') { + method = 'editor'; + } + + if (!this.validateForm(method)) { + return; + } + + this.state.actionInProgress = true; + try { + await this.createStackByMethod(name, method); + + this.Notifications.success('Stack successfully deployed'); + this.$state.go('edge.stacks'); + } catch (err) { + this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); + } finally { + this.state.actionInProgress = false; + } + } + + validateForm(method) { + this.state.formValidationError = ''; + + if (method === 'editor' && this.formValues.StackFileContent === '') { + this.state.formValidationError = 'Stack file content must not be empty'; + return; + } + + return true; + } + + createStackByMethod(name, method) { + switch (method) { + case 'editor': + return this.createStackFromFileContent(name); + case 'upload': + return this.createStackFromFileUpload(name); + case 'repository': + return this.createStackFromGitRepository(name); + } + } + + createStackFromFileContent(name) { + return this.EdgeStackService.createStackFromFileContent(name, this.formValues.StackFileContent, this.formValues.Groups); + } + + createStackFromFileUpload(name) { + return this.EdgeStackService.createStackFromFileUpload(name, this.formValues.StackFile, this.formValues.Groups); + } + + createStackFromGitRepository(name) { + const repositoryOptions = { + RepositoryURL: this.formValues.RepositoryURL, + RepositoryReferenceName: this.formValues.RepositoryReferenceName, + ComposeFilePathInRepository: this.formValues.ComposeFilePathInRepository, + RepositoryAuthentication: this.formValues.RepositoryAuthentication, + RepositoryUsername: this.formValues.RepositoryUsername, + RepositoryPassword: this.formValues.RepositoryPassword, + }; + return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups); + } + + editorUpdate(cm) { + this.formValues.StackFileContent = cm.getValue(); + } +} + +angular.module('portainer.edge').controller('CreateEdgeStackViewController', CreateEdgeStackViewController); +export default CreateEdgeStackViewController; diff --git a/app/edge/views/edge-stacks/edgeStacksView.html b/app/edge/views/edge-stacks/edgeStacksView.html new file mode 100644 index 000000000..4b712b312 --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksView.html @@ -0,0 +1,21 @@ + + + + + + + Edge Stacks + +
+
+ +
+
diff --git a/app/edge/views/edge-stacks/edgeStacksView.js b/app/edge/views/edge-stacks/edgeStacksView.js new file mode 100644 index 000000000..27e58493c --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('edgeStacksView', { + templateUrl: './edgeStacksView.html', + controller: 'EdgeStacksViewController', +}); diff --git a/app/edge/views/edge-stacks/edgeStacksViewController.js b/app/edge/views/edge-stacks/edgeStacksViewController.js new file mode 100644 index 000000000..9aae3770d --- /dev/null +++ b/app/edge/views/edge-stacks/edgeStacksViewController.js @@ -0,0 +1,52 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeStacksViewController { + constructor($state, Notifications, EdgeStackService, $scope, $async) { + this.$state = $state; + this.Notifications = Notifications; + this.EdgeStackService = EdgeStackService; + this.$scope = $scope; + this.$async = $async; + + this.stacks = undefined; + + this.getStacks = this.getStacks.bind(this); + this.removeAction = this.removeAction.bind(this); + this.removeActionAsync = this.removeActionAsync.bind(this); + } + + $onInit() { + this.getStacks(); + } + + removeAction(stacks) { + return this.$async(this.removeActionAsync, stacks); + } + + async removeActionAsync(stacks) { + for (let stack of stacks) { + try { + await this.EdgeStackService.remove(stack.Id); + this.Notifications.success('Stack successfully removed', stack.Name); + _.remove(this.stacks, stack); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); + } + } + + this.$state.reload(); + } + + async getStacks() { + try { + this.stacks = await this.EdgeStackService.stacks(); + } catch (err) { + this.stacks = []; + this.Notifications.error('Failure', err, 'Unable to retrieve stacks'); + } + } +} + +angular.module('portainer.edge').controller('EdgeStacksViewController', EdgeStacksViewController); +export default EdgeStacksViewController; diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackView.html b/app/edge/views/edge-stacks/edit/editEdgeStackView.html new file mode 100644 index 000000000..6a64ae1e9 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackView.html @@ -0,0 +1,43 @@ + + + Edge Stacks > {{ $ctrl.stack.Name }} + + +
+
+ + + + + + Stack + +
+ +
+
+ + Endpoints + +
+ + +
+
+
+
+
+
+
diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackView.js b/app/edge/views/edge-stacks/edit/editEdgeStackView.js new file mode 100644 index 000000000..9f64573d5 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('editEdgeStackView', { + templateUrl: './editEdgeStackView.html', + controller: 'EditEdgeStackViewController', +}); diff --git a/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js b/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js new file mode 100644 index 000000000..c691d0541 --- /dev/null +++ b/app/edge/views/edge-stacks/edit/editEdgeStackViewController.js @@ -0,0 +1,94 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EditEdgeStackViewController { + constructor($async, $state, EdgeGroupService, EdgeStackService, EndpointService, Notifications) { + this.$async = $async; + this.$state = $state; + this.EdgeGroupService = EdgeGroupService; + this.EdgeStackService = EdgeStackService; + this.EndpointService = EndpointService; + this.Notifications = Notifications; + + this.stack = null; + this.edgeGroups = null; + + this.state = { + actionInProgress: false, + }; + + this.deployStack = this.deployStack.bind(this); + this.deployStackAsync = this.deployStackAsync.bind(this); + this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this); + this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this); + } + + async $onInit() { + const { stackId } = this.$state.params; + try { + const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]); + this.edgeGroups = edgeGroups; + this.stack = model; + this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups); + this.originalFileContent = file; + this.formValues = { + StackFileContent: file, + EdgeGroups: this.stack.EdgeGroups, + Prune: this.stack.Prune, + }; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); + } + } + + filterStackEndpoints(groupIds, groups) { + return _.flatten( + _.map(groupIds, (Id) => { + const group = _.find(groups, { Id }); + return group.Endpoints; + }) + ); + } + + deployStack() { + return this.$async(this.deployStackAsync); + } + + async deployStackAsync() { + this.state.actionInProgress = true; + try { + if (this.originalFileContent != this.formValues.StackFileContent) { + this.formValues.Version = this.stack.Version + 1; + } + await this.EdgeStackService.updateStack(this.stack.Id, this.formValues); + this.Notifications.success('Stack successfully deployed'); + this.$state.go('edge.stacks'); + } catch (err) { + this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); + } finally { + this.state.actionInProgress = false; + } + } + + getPaginatedEndpoints(...args) { + return this.$async(this.getPaginatedEndpointsAsync, ...args); + } + + async getPaginatedEndpointsAsync(lastId, limit, search) { + try { + const query = { search, type: 4, endpointIds: this.stackEndpointIds }; + const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query); + const endpoints = _.map(value, (endpoint) => { + const status = this.stack.Status[endpoint.Id]; + endpoint.Status = status; + return endpoint; + }); + return { endpoints, totalCount }; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + } + } +} + +angular.module('portainer.edge').controller('EditEdgeStackViewController', EditEdgeStackViewController); +export default EditEdgeStackViewController; diff --git a/app/edge/views/groups/create/createEdgeGroupView.html b/app/edge/views/groups/create/createEdgeGroupView.html new file mode 100644 index 000000000..2732b58e0 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupView.html @@ -0,0 +1,23 @@ + + + Edge groups > Add edge group + + +
+
+ + + + + +
+
diff --git a/app/edge/views/groups/create/createEdgeGroupView.js b/app/edge/views/groups/create/createEdgeGroupView.js new file mode 100644 index 000000000..e6778e728 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('createEdgeGroupView', { + templateUrl: './createEdgeGroupView.html', + controller: 'CreateEdgeGroupController', +}); diff --git a/app/edge/views/groups/create/createEdgeGroupViewController.js b/app/edge/views/groups/create/createEdgeGroupViewController.js new file mode 100644 index 000000000..401f4c814 --- /dev/null +++ b/app/edge/views/groups/create/createEdgeGroupViewController.js @@ -0,0 +1,56 @@ +import angular from 'angular'; + +class CreateEdgeGroupController { + /* @ngInject */ + constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async) { + this.EdgeGroupService = EdgeGroupService; + this.GroupService = GroupService; + this.TagService = TagService; + this.Notifications = Notifications; + this.$state = $state; + this.$async = $async; + + this.state = { + actionInProgress: false, + loaded: false, + }; + + this.model = { + Name: '', + Endpoints: [], + Dynamic: false, + TagIds: [], + PartialMatch: false, + }; + + this.createGroup = this.createGroup.bind(this); + this.createGroupAsync = this.createGroupAsync.bind(this); + } + + async $onInit() { + const [tags, endpointGroups] = await Promise.all([this.TagService.tags(), this.GroupService.groups()]); + this.tags = tags; + this.endpointGroups = endpointGroups; + this.state.loaded = true; + } + + createGroup() { + return this.$async(this.createGroupAsync); + } + + async createGroupAsync() { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.create(this.model); + this.Notifications.success('Edge group successfully created'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create edge group'); + } finally { + this.state.actionInProgress = false; + } + } +} + +angular.module('portainer.edge').controller('CreateEdgeGroupController', CreateEdgeGroupController); +export default CreateEdgeGroupController; diff --git a/app/edge/views/groups/edgeGroupsView.html b/app/edge/views/groups/edgeGroupsView.html new file mode 100644 index 000000000..4114603fc --- /dev/null +++ b/app/edge/views/groups/edgeGroupsView.html @@ -0,0 +1,14 @@ + + + + + + + Edge Groups + + +
+
+ +
+
diff --git a/app/edge/views/groups/edgeGroupsView.js b/app/edge/views/groups/edgeGroupsView.js new file mode 100644 index 000000000..09f0202af --- /dev/null +++ b/app/edge/views/groups/edgeGroupsView.js @@ -0,0 +1,6 @@ +import angular from 'angular'; + +angular.module('portainer.edge').component('edgeGroupsView', { + templateUrl: './edgeGroupsView.html', + controller: 'EdgeGroupsController', +}); diff --git a/app/edge/views/groups/edgeGroupsViewController.js b/app/edge/views/groups/edgeGroupsViewController.js new file mode 100644 index 000000000..d589f7851 --- /dev/null +++ b/app/edge/views/groups/edgeGroupsViewController.js @@ -0,0 +1,40 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +class EdgeGroupsController { + /* @ngInject */ + constructor($async, $state, EdgeGroupService, Notifications) { + this.$async = $async; + this.$state = $state; + this.EdgeGroupService = EdgeGroupService; + this.Notifications = Notifications; + + this.removeAction = this.removeAction.bind(this); + this.removeActionAsync = this.removeActionAsync.bind(this); + } + + async $onInit() { + this.items = await this.EdgeGroupService.groups(); + } + + removeAction(selectedItems) { + return this.$async(this.removeActionAsync, selectedItems); + } + + async removeActionAsync(selectedItems) { + for (let item of selectedItems) { + try { + await this.EdgeGroupService.remove(item.Id); + + this.Notifications.success('Edge Group successfully removed', item.Name); + _.remove(this.items, item); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to remove Edge Group'); + } + } + + this.$state.reload(); + } +} + +angular.module('portainer.edge').controller('EdgeGroupsController', EdgeGroupsController); diff --git a/app/edge/views/groups/edit/editEdgeGroupView.html b/app/edge/views/groups/edit/editEdgeGroupView.html new file mode 100644 index 000000000..ba26f203f --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupView.html @@ -0,0 +1,23 @@ + + + Edge Groups > {{ $ctrl.model.Name }} + + +
+
+ + + + + +
+
diff --git a/app/edge/views/groups/edit/editEdgeGroupView.js b/app/edge/views/groups/edit/editEdgeGroupView.js new file mode 100644 index 000000000..7cb21ef3b --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupView.js @@ -0,0 +1,4 @@ +angular.module('portainer.edge').component('editEdgeGroupView', { + templateUrl: './editEdgeGroupView.html', + controller: 'EditEdgeGroupController', +}); diff --git a/app/edge/views/groups/edit/editEdgeGroupViewController.js b/app/edge/views/groups/edit/editEdgeGroupViewController.js new file mode 100644 index 000000000..1685fbb50 --- /dev/null +++ b/app/edge/views/groups/edit/editEdgeGroupViewController.js @@ -0,0 +1,56 @@ +import angular from 'angular'; + +class EditEdgeGroupController { + /* @ngInject */ + constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async, EndpointService, EndpointHelper) { + this.EdgeGroupService = EdgeGroupService; + this.GroupService = GroupService; + this.TagService = TagService; + this.Notifications = Notifications; + this.$state = $state; + this.$async = $async; + this.EndpointService = EndpointService; + this.EndpointHelper = EndpointHelper; + + this.state = { + actionInProgress: false, + loaded: false, + }; + + this.updateGroup = this.updateGroup.bind(this); + this.updateGroupAsync = this.updateGroupAsync.bind(this); + } + + async $onInit() { + const [tags, endpointGroups, group] = await Promise.all([this.TagService.tags(), this.GroupService.groups(), this.EdgeGroupService.group(this.$state.params.groupId)]); + + if (!group) { + this.Notifications.error('Failed to find edge group', {}); + this.$state.go('edge.groups'); + } + this.tags = tags; + this.endpointGroups = endpointGroups; + this.model = group; + this.state.loaded = true; + } + + updateGroup() { + return this.$async(this.updateGroupAsync); + } + + async updateGroupAsync() { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.update(this.model); + this.Notifications.success('Edge group successfully updated'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update edge group'); + } finally { + this.state.actionInProgress = false; + } + } +} + +angular.module('portainer.edge').controller('EditEdgeGroupController', EditEdgeGroupController); +export default EditEdgeGroupController; diff --git a/app/portainer/components/code-editor/codeEditorController.js b/app/portainer/components/code-editor/codeEditorController.js index dba976e17..66e24666f 100644 --- a/app/portainer/components/code-editor/codeEditorController.js +++ b/app/portainer/components/code-editor/codeEditorController.js @@ -1,20 +1,16 @@ -angular.module('portainer.app').controller('CodeEditorController', [ - '$document', - 'CodeMirrorService', - function ($document, CodeMirrorService) { - var ctrl = this; +angular.module('portainer.app').controller('CodeEditorController', function CodeEditorController($document, CodeMirrorService, $scope) { + var ctrl = this; - this.$onInit = function () { - $document.ready(function () { - var editorElement = $document[0].getElementById(ctrl.identifier); - ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly); - if (ctrl.onChange) { - ctrl.editor.on('change', ctrl.onChange); - } - if (ctrl.value) { - ctrl.editor.setValue(ctrl.value); - } - }); - }; - }, -]); + this.$onInit = function () { + $document.ready(function () { + var editorElement = $document[0].getElementById(ctrl.identifier); + ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly); + if (ctrl.onChange) { + ctrl.editor.on('change', (...args) => $scope.$apply(() => ctrl.onChange(...args))); + } + if (ctrl.value) { + ctrl.editor.setValue(ctrl.value); + } + }); + }; +}); diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 7dfe3b49b..b12bc5bd4 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -42,11 +42,12 @@ angular.module('portainer.app').controller('GenericDatatableController', [ DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); }; - this.changeOrderBy = function (orderField) { + this.changeOrderBy = changeOrderBy.bind(this); + function changeOrderBy(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.selectItem = function (item, event) { // Handle range select using shift diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html index 404ae02a2..6b0b81cb4 100644 --- a/app/portainer/components/forms/group-form/groupForm.html +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -77,6 +77,7 @@ entry-click="$ctrl.dissociateEndpoint" pagination-state="$ctrl.state.associated" empty-dataset-message="No associated endpoint" + has-backend-pagination="this.pageType !== 'create'" > diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html index f6e6054ba..e08177217 100644 --- a/app/portainer/components/forms/schedule-form/scheduleForm.html +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -269,6 +269,7 @@ - - Name - - - + Name + + + Group + + + Tags - {{ item.Name }} + + {{ item.Name | truncate: 64 }} + + + {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate: 64 }} + + + {{ $ctrl.tagIdsToTagNames(item.TagIds) | arraytostr | truncate: 64 }} + - {{ item.Name }} + + {{ item.Name | truncate: 64 }} + + + {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate: 64 }} + + + {{ $ctrl.tagIdsToTagNames(item.TagIds) | truncate: 64 }} + + - Loading... + Loading... - {{ $ctrl.emptyDatasetMessage }} + {{ $ctrl.emptyDatasetMessage }} diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html index 394531a4f..adb33e059 100644 --- a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html @@ -1,14 +1,18 @@ - + {{ $item.Name }} - ({{ $ctrl.tagIdsToTagNames($item.TagIds) | arraytostr }}) + - {{ $ctrl.tagIdsToTagNames($item.TagIds) | arraytostr }} - + {{ endpoint.Name }} - ({{ $ctrl.tagIdsToTagNames(endpoint.TagIds) | arraytostr }}) + - {{ $ctrl.tagIdsToTagNames(endpoint.TagIds) | arraytostr }} diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js index bcf41d7df..608825904 100644 --- a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js @@ -29,9 +29,19 @@ class MultiEndpointSelectorController { return PortainerEndpointTagHelper.idsToTagNames(this.tags, tagIds); } - $onInit() { + filterEmptyGroups() { this.availableGroups = _.filter(this.groups, (group) => _.some(this.endpoints, (endpoint) => endpoint.GroupId == group.Id)); } + + $onInit() { + this.filterEmptyGroups(); + } + + $onChanges({ endpoints, groups }) { + if (endpoints || groups) { + this.filterEmptyGroups(); + } + } } export default MultiEndpointSelectorController; diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index b49a7a989..7a6324005 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -12,6 +12,7 @@ export function SettingsViewModel(data) { this.ExternalTemplates = data.ExternalTemplates; this.EnableHostManagementFeatures = data.EnableHostManagementFeatures; this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval; + this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures; } export function PublicSettingsViewModel(settings) { @@ -21,6 +22,7 @@ export function PublicSettingsViewModel(settings) { this.AuthenticationMethod = settings.AuthenticationMethod; this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; this.ExternalTemplates = settings.ExternalTemplates; + this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; this.LogoURL = settings.LogoURL; this.OAuthLoginURI = settings.OAuthLoginURI; } diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 018c2b30d..becb4efbf 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -10,8 +10,8 @@ angular.module('portainer.app').factory('EndpointService', [ return Endpoints.get({ id: endpointID }).$promise; }; - service.endpoints = function (start, limit, { search, type, tagIds, endpointIds } = {}) { - return Endpoints.query({ start, limit, search, type, tagIds, endpointIds }).$promise; + service.endpoints = function (start, limit, { search, type, tagIds, endpointIds, tagsPartialMatch } = {}) { + return Endpoints.query({ start, limit, search, type, tagIds: JSON.stringify(tagIds), endpointIds: JSON.stringify(endpointIds), tagsPartialMatch }).$promise; }; service.snapshotEndpoints = function () { diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 25aa6b756..6a571e47a 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -85,6 +85,19 @@ angular.module('portainer.app').factory('FileUploadService', [ }); }; + service.createEdgeStack = function createEdgeStack(stackName, file, edgeGroups) { + return Upload.upload({ + url: 'api/edge_stacks?method=file', + data: { + file: file, + Name: stackName, + EdgeGroups: Upload.json(edgeGroups) + }, + ignoreLoadingBar: true + }); + }; + + service.configureRegistry = function (registryId, registryManagementConfigurationModel) { return Upload.upload({ url: 'api/registries/' + registryId + '/configure', diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index e9b4ab908..1fda3ae68 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -71,6 +71,11 @@ angular.module('portainer.app').factory('StateManager', [ LocalStorage.storeApplicationState(state.application); }; + manager.updateEnableEdgeComputeFeatures = function updateEnableEdgeComputeFeatures(enableEdgeComputeFeatures) { + state.application.enableEdgeComputeFeatures = enableEdgeComputeFeatures; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; @@ -81,6 +86,7 @@ angular.module('portainer.app').factory('StateManager', [ state.application.snapshotInterval = settings.SnapshotInterval; state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures; state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers; + state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; state.application.validity = moment().unix(); } diff --git a/app/portainer/views/endpoints/endpointsController.js b/app/portainer/views/endpoints/endpointsController.js index 336a86dce..df3c72781 100644 --- a/app/portainer/views/endpoints/endpointsController.js +++ b/app/portainer/views/endpoints/endpointsController.js @@ -1,48 +1,44 @@ -angular.module('portainer.app').controller('EndpointsController', [ - '$q', - '$scope', - '$state', - 'EndpointService', - 'GroupService', - 'EndpointHelper', - 'Notifications', - function ($q, $scope, $state, EndpointService, GroupService, EndpointHelper, Notifications) { - $scope.removeAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (endpoint) { - EndpointService.deleteEndpoint(endpoint.Id) - .then(function success() { - Notifications.success('Endpoint successfully removed', endpoint.Name); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove endpoint'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; +import angular from 'angular'; - $scope.getPaginatedEndpoints = getPaginatedEndpoints; - function getPaginatedEndpoints(lastId, limit, search) { - const deferred = $q.defer(); - $q.all({ - endpoints: EndpointService.endpoints(lastId, limit, { search }), - groups: GroupService.groups(), - }) - .then(function success(data) { - var endpoints = data.endpoints.value; - var groups = data.groups; - EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); - deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount }); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); - }); - return deferred.promise; +angular.module('portainer.app').controller('EndpointsController', EndpointsController); + +function EndpointsController($q, $scope, $state, $async, EndpointService, GroupService, EndpointHelper, Notifications) { + $scope.removeAction = removeAction; + + function removeAction(endpoints) { + return $async(removeActionAsync, endpoints); + } + + async function removeActionAsync(endpoints) { + for (let endpoint of endpoints) { + try { + await EndpointService.deleteEndpoint(endpoint.Id); + + Notifications.success('Endpoint successfully removed', endpoint.Name); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove endpoint'); + } } - }, -]); + + $state.reload(); + } + + $scope.getPaginatedEndpoints = getPaginatedEndpoints; + function getPaginatedEndpoints(lastId, limit, search) { + const deferred = $q.defer(); + $q.all({ + endpoints: EndpointService.endpoints(lastId, limit, { search }), + groups: GroupService.groups(), + }) + .then(function success(data) { + var endpoints = data.endpoints.value; + var groups = data.groups; + EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); + deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + return deferred.promise; + } +} diff --git a/app/portainer/views/groups/groupsController.js b/app/portainer/views/groups/groupsController.js index 1b089850e..67fc81af6 100644 --- a/app/portainer/views/groups/groupsController.js +++ b/app/portainer/views/groups/groupsController.js @@ -1,42 +1,40 @@ -angular.module('portainer.app').controller('GroupsController', [ - '$scope', - '$state', - '$filter', - 'GroupService', - 'Notifications', - function ($scope, $state, $filter, GroupService, Notifications) { - $scope.removeAction = function (selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (group) { - GroupService.deleteGroup(group.Id) - .then(function success() { - Notifications.success('Endpoint group successfully removed', group.Name); - var index = $scope.groups.indexOf(group); - $scope.groups.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove group'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }; +import angular from 'angular'; +import _ from 'lodash-es'; - function initView() { - GroupService.groups() - .then(function success(data) { - $scope.groups = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); - $scope.groups = []; - }); +angular.module('portainer.app').controller('GroupsController', GroupsController); + +function GroupsController($scope, $state, $async, GroupService, Notifications) { + $scope.removeAction = removeAction; + + function removeAction(selectedItems) { + return $async(removeActionAsync, selectedItems); + } + + async function removeActionAsync(selectedItems) { + for (let group of selectedItems) { + try { + await GroupService.deleteGroup(group.Id); + + Notifications.success('Endpoint group successfully removed', group.Name); + _.remove($scope.groups, group); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove group'); + } } - initView(); - }, -]); + $state.reload(); + } + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); + $scope.groups = []; + }); + } + + initView(); +} diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 5d55db506..3f3da4b74 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -123,22 +123,30 @@
- Edge + Edge Compute +
+
+ +
+ +
-
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 16d0e37cb..eba237506 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -32,6 +32,7 @@ angular.module('portainer.app').controller('SettingsController', [ labelValue: '', enableHostManagementFeatures: false, enableVolumeBrowser: false, + enableEdgeComputeFeatures: false, }; $scope.removeFilteredContainerLabel = function (index) { @@ -67,6 +68,7 @@ angular.module('portainer.app').controller('SettingsController', [ settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; settings.AllowVolumeBrowserForRegularUsers = $scope.formValues.enableVolumeBrowser; settings.EnableHostManagementFeatures = $scope.formValues.enableHostManagementFeatures; + settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures; $scope.state.actionInProgress = true; updateSettings(settings); @@ -80,6 +82,7 @@ angular.module('portainer.app').controller('SettingsController', [ StateManager.updateSnapshotInterval(settings.SnapshotInterval); StateManager.updateEnableHostManagementFeatures(settings.EnableHostManagementFeatures); StateManager.updateEnableVolumeBrowserForNonAdminUsers(settings.AllowVolumeBrowserForRegularUsers); + StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures); $state.reload(); }) .catch(function error(err) { @@ -105,6 +108,7 @@ angular.module('portainer.app').controller('SettingsController', [ $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; $scope.formValues.enableVolumeBrowser = settings.AllowVolumeBrowserForRegularUsers; $scope.formValues.enableHostManagementFeatures = settings.EnableHostManagementFeatures; + $scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index a16482a1e..8e1a3f4d9 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -91,6 +91,15 @@ + + + diff --git a/app/portainer/views/tags/tagsController.js b/app/portainer/views/tags/tagsController.js index ae0477df7..ae86039a9 100644 --- a/app/portainer/views/tags/tagsController.js +++ b/app/portainer/views/tags/tagsController.js @@ -1,72 +1,71 @@ -angular.module('portainer.app').controller('TagsController', [ - '$scope', - '$state', - 'TagService', - 'Notifications', - function ($scope, $state, TagService, Notifications) { - $scope.state = { - actionInProgress: false, - }; +import angular from 'angular'; +import _ from 'lodash-es'; - $scope.formValues = { - Name: '', - }; +angular.module('portainer.app').controller('TagsController', TagsController); - $scope.checkNameValidity = function (form) { - var valid = true; - for (var i = 0; i < $scope.tags.length; i++) { - if ($scope.formValues.Name === $scope.tags[i].Name) { - valid = false; - break; - } +function TagsController($scope, $state, $async, TagService, Notifications) { + $scope.state = { + actionInProgress: false, + }; + + $scope.formValues = { + Name: '', + }; + + $scope.checkNameValidity = function (form) { + var valid = true; + for (var i = 0; i < $scope.tags.length; i++) { + if ($scope.formValues.Name === $scope.tags[i].Name) { + valid = false; + break; } - form.name.$setValidity('validName', valid); - }; + } + form.name.$setValidity('validName', valid); + }; - $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.removeAction = removeAction; - $scope.createTag = function () { - var tagName = $scope.formValues.Name; - TagService.createTag(tagName) - .then(function success() { - Notifications.success('Tag successfully created', tagName); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to create tag'); - }); - }; + function removeAction(tags) { + return $async(removeActionAsync, tags); + } - 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 = []; - }); + async function removeActionAsync(tags) { + for (let tag of tags) { + try { + await TagService.deleteTag(tag.Id); + + Notifications.success('Tag successfully removed', tag.Name); + _.remove($scope.tags, tag); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove tag'); + } } - initView(); - }, -]); + $state.reload(); + } + + $scope.createTag = function () { + var tagName = $scope.formValues.Name; + TagService.createTag(tagName) + .then(function success() { + 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(); +}