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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
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 @@
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
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();
+}