diff --git a/.eslintrc.yml b/.eslintrc.yml
index 8e667de7c..b6829dd81 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -10,6 +10,10 @@ globals:
extends:
- 'eslint:recommended'
+ - prettier
+
+plugins:
+ - import
parserOptions:
ecmaVersion: 2018
@@ -17,276 +21,9 @@ parserOptions:
ecmaFeatures:
modules: true
-# # http://eslint.org/docs/rules/
rules:
-# # Possible Errors
-# no-await-in-loop: off
-# no-cond-assign: error
-# no-console: off
-# no-constant-condition: error
no-control-regex: off
-# no-debugger: error
-# no-dupe-args: error
-# no-dupe-keys: error
-# no-duplicate-case: error
-# no-empty-character-class: error
no-empty: warn
-# no-ex-assign: error
-# no-extra-boolean-cast: error
-# no-extra-parens: off
-# no-extra-semi: error
-# no-func-assign: error
-# no-inner-declarations:
-# - error
-# - functions
-# no-invalid-regexp: error
-# no-irregular-whitespace: error
-# no-negated-in-lhs: error
-# no-obj-calls: error
-# no-prototype-builtins: off
-# no-regex-spaces: error
-# no-sparse-arrays: error
-# no-template-curly-in-string: off
-# no-unexpected-multiline: error
-# no-unreachable: error
-# no-unsafe-finally: off
-# no-unsafe-negation: off
-# use-isnan: error
-# valid-jsdoc: off
-# valid-typeof: error
-
-# # Best Practices
-# accessor-pairs: error
-# array-callback-return: off
-# block-scoped-var: off
-# class-methods-use-this: off
-# complexity:
-# - error
-# - 6
-# consistent-return: off
-# curly: off
-# default-case: off
-# dot-location: off
-# dot-notation: off
-# eqeqeq: error
-# guard-for-in: error
-# no-alert: error
-# no-caller: error
-# no-case-declarations: error
-# no-div-regex: error
-# no-else-return: off
no-empty-function: warn
-# no-empty-pattern: error
-# no-eq-null: error
-# no-eval: error
-# no-extend-native: error
-# no-extra-bind: error
-# no-extra-label: off
-# no-fallthrough: error
-# no-floating-decimal: off
-# no-global-assign: off
-# no-implicit-coercion: off
-# no-implied-eval: error
-# no-invalid-this: off
-# no-iterator: error
-# no-labels:
-# - error
-# - allowLoop: true
-# allowSwitch: true
-# no-lone-blocks: error
-# no-loop-func: error
-# no-magic-number: off
-# no-multi-spaces: off
-# no-multi-str: off
-# no-native-reassign: error
-# no-new-func: error
-# no-new-wrappers: error
-# no-new: error
-# no-octal-escape: error
-# no-octal: error
-# no-param-reassign: off
-# no-proto: error
-# no-redeclare: error
-# no-restricted-properties: off
-# no-return-assign: error
-# no-return-await: off
-# no-script-url: error
-# no-self-assign: off
-# no-self-compare: error
-# no-sequences: off
-# no-throw-literal: off
-# no-unmodified-loop-condition: off
-# no-unused-expressions: error
-# no-unused-labels: off
-# no-useless-call: error
-# no-useless-concat: error
no-useless-escape: off
-# no-useless-return: off
-# no-void: error
-# no-warning-comments: off
-# no-with: error
-# prefer-promise-reject-errors: off
-# radix: error
-# require-await: off
-# vars-on-top: off
-# wrap-iife: error
-# yoda: off
-
-# # Strict
-# strict: off
-
-# # Variables
-# init-declarations: off
-# no-catch-shadow: error
-# no-delete-var: error
-# no-label-var: error
-# no-restricted-globals: off
-# no-shadow-restricted-names: error
-# no-shadow: off
-# no-undef-init: error
-# no-undef: off
-# no-undefined: off
-# no-unused-vars:
-# - warn
-# -
-# vars: local
-# no-use-before-define: off
-
-# # Node.js and CommonJS
-# callback-return: error
-# global-require: error
-# handle-callback-err: error
-# no-mixed-requires: off
-# no-new-require: off
-# no-path-concat: error
-# no-process-env: off
-# no-process-exit: error
-# no-restricted-modules: off
-# no-sync: off
-
-# # Stylistic Issues
-# array-bracket-spacing: off
-# block-spacing: off
-# brace-style: off
-# camelcase: off
-# capitalized-comments: off
-# comma-dangle:
-# - error
-# - never
-# comma-spacing: off
-# comma-style: off
-# computed-property-spacing: off
-# consistent-this: off
-# eol-last: off
-# func-call-spacing: off
-# func-name-matching: off
-# func-names: off
-# func-style: off
-# id-length: off
-# id-match: off
-# indent: off
-# jsx-quotes: off
-# key-spacing: off
-# keyword-spacing: off
-# line-comment-position: off
-# linebreak-style:
-# - error
-# - unix
-# lines-around-comment: off
-# lines-around-directive: off
-# max-depth: off
-# max-len: off
-# max-nested-callbacks: off
-# max-params: off
-# max-statements-per-line: off
-# max-statements:
-# - error
-# - 30
-# multiline-ternary: off
-# new-cap: off
-# new-parens: off
-# newline-after-var: off
-# newline-before-return: off
-# newline-per-chained-call: off
-# no-array-constructor: off
-# no-bitwise: off
-# no-continue: off
-# no-inline-comments: off
-# no-lonely-if: off
-# no-mixed-operators: off
-# no-mixed-spaces-and-tabs: off
-# no-multi-assign: off
-# no-multiple-empty-lines: off
-# no-negated-condition: off
-# no-nested-ternary: off
-# no-new-object: off
-# no-plusplus: off
-# no-restricted-syntax: off
-# no-spaced-func: off
-# no-tabs: off
-# no-ternary: off
-# no-trailing-spaces: off
-# no-underscore-dangle: off
-# no-unneeded-ternary: off
-# object-curly-newline: off
-# object-curly-spacing: off
-# object-property-newline: off
-# one-var-declaration-per-line: off
-# one-var: off
-# operator-assignment: off
-# operator-linebreak: off
-# padded-blocks: off
-# quote-props: off
-# quotes:
-# - error
-# - single
-# require-jsdoc: off
-# semi-spacing: off
-# semi:
-# - error
-# - always
-# sort-keys: off
-# sort-vars: off
-# space-before-blocks: off
-# space-before-function-paren: off
-# space-in-parens: off
-# space-infix-ops: off
-# space-unary-ops: off
-# spaced-comment: off
-# template-tag-spacing: off
-# unicode-bom: off
-# wrap-regex: off
-
-# # ECMAScript 6
-# arrow-body-style: off
-# arrow-parens: off
-# arrow-spacing: off
-# constructor-super: off
-# generator-star-spacing: off
-# no-class-assign: off
-# no-confusing-arrow: off
-# no-const-assign: off
-# no-dupe-class-members: off
-# no-duplicate-imports: off
-# no-new-symbol: off
-# no-restricted-imports: off
-# no-this-before-super: off
-# no-useless-computed-key: off
-# no-useless-constructor: off
-# no-useless-rename: off
-# no-var: off
-# object-shorthand: off
-# prefer-arrow-callback: off
-# prefer-const: off
-# prefer-destructuring: off
-# prefer-numeric-literals: off
-# prefer-rest-params: off
-# prefer-reflect: off
-# prefer-spread: off
-# prefer-template: off
-# require-yield: off
-# rest-spread-spacing: off
-# sort-imports: off
-# symbol-description: off
-# template-curly-spacing: off
-# yield-star-spacing: off
+ import/order: error
diff --git a/.gitignore b/.gitignore
index 43b170ab0..d9c64e8bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ dist
portainer-checksum.txt
api/cmd/portainer/portainer*
.tmp
-.vscode
\ No newline at end of file
+.vscode
+.eslintcache
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..3217d14ee
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "printWidth": 180,
+ "singleQuote": true,
+ "htmlWhitespaceSensitivity": "strict",
+ "overrides": [
+ {
+ "files": ["*.html"],
+ "options": {
+ "parser": "angular"
+ }
+ }
+ ]
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e26559a7e..a3f4d5a37 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,21 +15,7 @@ For example, if you work on a bugfix for the issue #361, you could name the bran
## Issues open to contribution
-Want to contribute but don't know where to start?
-
-Some of the open issues are labeled with prefix `exp/`, this is used to mark them as available for contributors to work on. All of these have an attributed difficulty level:
-
-* **beginner**: a task that should be accessible with users not familiar with the codebase
-* **intermediate**: a task that require some understanding of the project codebase or some experience in
-either AngularJS or Golang
-* **advanced**: a task that require a deep understanding of the project codebase
-
-You can use Github filters to list these issues:
-
-* beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner
-* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
-* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
-
+Want to contribute but don't know where to start? Have a look at the issues labeled with the `good first issue` label: https://github.com/portainer/portainer/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22
## Commit Message Format
diff --git a/README.md b/README.md
index 86363fab2..4d5fe4230 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,14 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/)
-[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
-[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
+[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer 'Image size')
[![Build Status](https://portainer.visualstudio.com/Portainer%20CI/_apis/build/status/Portainer%20CI?branchName=develop)](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too).
-**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*.
+**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more) ! It is compatible with the _standalone Docker_ engine and with _Docker Swarm mode_.
## Demo
@@ -29,36 +28,31 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart
## Getting started
-* [Deploy Portainer](https://portainer.readthedocs.io/en/latest/deployment.html)
-* [Documentation](https://portainer.readthedocs.io)
+- [Deploy Portainer](https://www.portainer.io/installation/)
+- [Documentation](https://www.portainer.io/documentation/)
## Getting help
-**NOTE**: You can find more information about Portainer support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
+For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products-services/portainer-business-support/
-* Issues: https://github.com/portainer/portainer/issues
-* FAQ: https://portainer.readthedocs.io/en/latest/faq.html
-* Slack (chat): https://portainer.io/slack/
+For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
+
+- Issues: https://github.com/portainer/portainer/issues
+- FAQ: https://www.portainer.io/documentation/faqs/
+- Slack (chat): https://portainer.io/slack/
## Reporting bugs and contributing
-* Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
-* Want to help us build **_portainer_**? Follow our [contribution guidelines](https://portainer.readthedocs.io/en/latest/contribute.html) to build it locally and make a pull request. We need all the help we can get!
+- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
+- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://www.portainer.io/documentation/how-to-contribute/) to build it locally and make a pull request. We need all the help we can get!
## Security
-* Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to .
+- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to .
## Limitations
-**_Portainer_** has full support for the following Docker versions:
-
-* Docker 1.10 to the latest version
-* Standalone Docker Swarm >= 1.2.3 _(**NOTE:** Use of Standalone Docker Swarm is being discouraged since the introduction of built-in Swarm Mode in Docker. While older versions of Portainer had support for Standalone Docker Swarm, Portainer 1.17.0 and newer **do not** support it. However, the built-in Swarm Mode of Docker is fully supported.)_
-
-Partial support for the following Docker versions (some features may not be available):
-
-* Docker 1.9
+Portainer supports "Current - 2 docker versions only. Prior versions may operate, however these are not supported.
## Licensing
@@ -68,4 +62,4 @@ Portainer also contains the following code, which is licensed under the [MIT lic
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io)
-rdash-angular: Copyright (c) [2014] [Elliot Hesp]
+rdash-angular: Copyright (c) [2014][elliot hesp]
diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go
index a857375f3..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,22 +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,
- 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)
@@ -160,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
@@ -172,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/init.go b/api/bolt/init.go
index bc6e39be5..8e1a0661c 100644
--- a/api/bolt/init.go
+++ b/api/bolt/init.go
@@ -16,7 +16,7 @@ func (store *Store) Init() error {
Labels: []portainer.Pair{},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
- Tags: []string{},
+ TagIDs: []portainer.TagID{},
}
err = store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)
diff --git a/api/bolt/migrator/migrate_dbversion22.go b/api/bolt/migrator/migrate_dbversion22.go
new file mode 100644
index 000000000..4a132c348
--- /dev/null
+++ b/api/bolt/migrator/migrate_dbversion22.go
@@ -0,0 +1,92 @@
+package migrator
+
+import "github.com/portainer/portainer/api"
+
+func (m *Migrator) updateTagsToDBVersion23() error {
+ tags, err := m.tagService.Tags()
+ if err != nil {
+ return err
+ }
+
+ for _, tag := range tags {
+ 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()
+ if err != nil {
+ return err
+ }
+
+ for _, endpoint := range endpoints {
+ endpointTags := make([]portainer.TagID, 0)
+ for _, tagName := range endpoint.Tags {
+ tag, ok := tagsNameMap[tagName]
+ if ok {
+ endpointTags = append(endpointTags, tag.ID)
+ tag.Endpoints[endpoint.ID] = true
+ }
+ }
+ endpoint.TagIDs = endpointTags
+ err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
+ 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()
+ if err != nil {
+ return err
+ }
+
+ for _, endpointGroup := range endpointGroups {
+ endpointGroupTags := make([]portainer.TagID, 0)
+ for _, tagName := range endpointGroup.Tags {
+ tag, ok := tagsNameMap[tagName]
+ if ok {
+ endpointGroupTags = append(endpointGroupTags, tag.ID)
+ tag.EndpointGroups[endpointGroup.ID] = true
+ }
+ }
+ endpointGroup.TagIDs = endpointGroupTags
+ err = m.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup)
+ if err != nil {
+ return err
+ }
+ }
+
+ 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 885cc9ab8..f9028ccc8 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"
@@ -12,6 +13,7 @@ import (
"github.com/portainer/portainer/api/bolt/schedule"
"github.com/portainer/portainer/api/bolt/settings"
"github.com/portainer/portainer/api/bolt/stack"
+ "github.com/portainer/portainer/api/bolt/tag"
"github.com/portainer/portainer/api/bolt/teammembership"
"github.com/portainer/portainer/api/bolt/template"
"github.com/portainer/portainer/api/bolt/user"
@@ -21,64 +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
- 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
- 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,
- 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,
}
}
@@ -301,5 +309,18 @@ func (m *Migrator) Migrate() error {
}
}
+ // Portainer 1.24.0
+ if m.currentDBVersion < 23 {
+ err := m.updateTagsToDBVersion23()
+ if err != nil {
+ return err
+ }
+
+ err = m.updateEndpointsAndEndpointGroupsToDBVersion23()
+ if err != nil {
+ return err
+ }
+ }
+
return m.versionService.StoreDBVersion(portainer.DBVersion)
}
diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go
index d54ee6b76..ba0f44ba9 100644
--- a/api/bolt/tag/tag.go
+++ b/api/bolt/tag/tag.go
@@ -52,6 +52,19 @@ func (service *Service) Tags() ([]portainer.Tag, error) {
return tags, err
}
+// Tag returns a tag by ID.
+func (service *Service) Tag(ID portainer.TagID) (*portainer.Tag, error) {
+ var tag portainer.Tag
+ identifier := internal.Itob(int(ID))
+
+ err := internal.GetObject(service.db, BucketName, identifier, &tag)
+ if err != nil {
+ return nil, err
+ }
+
+ return &tag, nil
+}
+
// CreateTag creates a new tag.
func (service *Service) CreateTag(tag *portainer.Tag) error {
return service.db.Update(func(tx *bolt.Tx) error {
@@ -69,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/chisel/service.go b/api/chisel/service.go
index bb2d3357d..efb08db71 100644
--- a/api/chisel/service.go
+++ b/api/chisel/service.go
@@ -179,7 +179,7 @@ func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tun
}
endpointURL := endpoint.URL
- endpoint.URL = fmt.Sprintf("tcp://localhost:%d", tunnelPort)
+ endpoint.URL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnelPort)
snapshot, err := service.snapshotter.CreateSnapshot(endpoint)
if err != nil {
return err
diff --git a/api/cli/cli.go b/api/cli/cli.go
index ba1d4d829..775aa9242 100644
--- a/api/cli/cli.go
+++ b/api/cli/cli.go
@@ -1,6 +1,7 @@
package cli
import (
+ "log"
"time"
"github.com/portainer/portainer/api"
@@ -38,8 +39,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(),
- ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
- NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
+ ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints (deprecated)").String(),
+ NoAuth: kingpin.Flag("no-auth", "Disable authentication (deprecated)").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
@@ -49,15 +50,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
- SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
- Snapshot: kingpin.Flag("snapshot", "Start a background job to create endpoint snapshots").Default(defaultSnapshot).Bool(),
+ SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source (deprecated)").Default(defaultSyncInterval).String(),
+ Snapshot: kingpin.Flag("snapshot", "Start a background job to create endpoint snapshots (deprecated)").Default(defaultSnapshot).Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Templates: kingpin.Flag("templates", "URL to the templates definitions.").Short('t').String(),
- TemplateFile: kingpin.Flag("template-file", "Path to the templates (app) definitions on the filesystem").Default(defaultTemplateFile).String(),
+ TemplateFile: kingpin.Flag("template-file", "Path to the App templates definitions on the filesystem (deprecated)").Default(defaultTemplateFile).String(),
}
kingpin.Parse()
@@ -76,6 +77,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
// ValidateFlags validates the values of the flags.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
+ displayDeprecationWarnings(flags)
+
if *flags.EndpointURL != "" && *flags.ExternalEndpoints != "" {
return errEndpointExcludeExternal
}
@@ -116,6 +119,28 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
return nil
}
+func displayDeprecationWarnings(flags *portainer.CLIFlags) {
+ if *flags.ExternalEndpoints != "" {
+ log.Println("Warning: the --external-endpoint flag is deprecated and will likely be removed in a future version of Portainer.")
+ }
+
+ if *flags.SyncInterval != defaultSyncInterval {
+ log.Println("Warning: the --sync-interval flag is deprecated and will likely be removed in a future version of Portainer.")
+ }
+
+ if *flags.NoAuth {
+ log.Println("Warning: the --no-auth flag is deprecated and will likely be removed in a future version of Portainer.")
+ }
+
+ if !*flags.Snapshot {
+ log.Println("Warning: the --no-snapshot flag is deprecated and will likely be removed in a future version of Portainer.")
+ }
+
+ if *flags.TemplateFile != "" {
+ log.Println("Warning: the --template-file flag is deprecated and will likely be removed in a future version of Portainer.")
+ }
+}
+
func validateEndpointURL(endpointURL string) error {
if endpointURL != "" {
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index 236b64d35..a606fefd4 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -259,7 +259,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
LogoURL: *flags.Logo,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
- AnonymousMode: true,
+ AnonymousMode: true,
AutoCreateUsers: true,
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
@@ -397,7 +397,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
- Tags: []string{},
+ TagIDs: []portainer.TagID{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
@@ -440,7 +440,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
- Tags: []string{},
+ TagIDs: []portainer.TagID{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
@@ -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/docker/client.go b/api/docker/client.go
index e063ad984..c1bd7a8d0 100644
--- a/api/docker/client.go
+++ b/api/docker/client.go
@@ -81,7 +81,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portain
}
tunnel := reverseTunnelService.GetTunnelDetails(endpoint.ID)
- endpointURL := fmt.Sprintf("http://localhost:%d", tunnel.Port)
+ endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
return client.NewClientWithOpts(
client.WithHost(endpointURL),
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/exec/swarm_stack.go b/api/exec/swarm_stack.go
index e50eacb63..d5b779a02 100644
--- a/api/exec/swarm_stack.go
+++ b/api/exec/swarm_stack.go
@@ -123,7 +123,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID)
- endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port)
+ endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
}
args = append(args, "-H", endpointURL)
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 32a617c92..f296fee64 100644
--- a/api/http/handler/endpointgroups/endpointgroup_create.go
+++ b/api/http/handler/endpointgroups/endpointgroup_create.go
@@ -14,15 +14,15 @@ type endpointGroupCreatePayload struct {
Name string
Description string
AssociatedEndpoints []portainer.EndpointID
- Tags []string
+ TagIDs []portainer.TagID
}
func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid endpoint group name")
}
- if payload.Tags == nil {
- payload.Tags = []string{}
+ if payload.TagIDs == nil {
+ payload.TagIDs = []portainer.TagID{}
}
return nil
}
@@ -40,7 +40,7 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque
Description: payload.Description,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
- Tags: payload.Tags,
+ TagIDs: payload.TagIDs,
}
err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup)
@@ -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 58ea605fc..362b8b697 100644
--- a/api/http/handler/endpointgroups/endpointgroup_update.go
+++ b/api/http/handler/endpointgroups/endpointgroup_update.go
@@ -13,7 +13,7 @@ import (
type endpointGroupUpdatePayload struct {
Name string
Description string
- Tags []string
+ TagIDs []portainer.TagID
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
}
@@ -50,8 +50,44 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
endpointGroup.Description = payload.Description
}
- if payload.Tags != nil {
- endpointGroup.Tags = payload.Tags
+ tagsChanged := false
+ if payload.TagIDs != nil {
+ 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 865923149..b7d08f320 100644
--- a/api/http/handler/endpoints/endpoint_create.go
+++ b/api/http/handler/endpoints/endpoint_create.go
@@ -32,7 +32,7 @@ type endpointCreatePayload struct {
AzureApplicationID string
AzureTenantID string
AzureAuthenticationKey string
- Tags []string
+ TagIDs []portainer.TagID
}
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
@@ -54,14 +54,14 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
}
payload.GroupID = groupID
- var tags []string
- err = request.RetrieveMultiPartFormJSONValue(r, "Tags", &tags, true)
+ var tagIDs []portainer.TagID
+ err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true)
if err != nil {
- return portainer.Error("Invalid Tags parameter")
+ return portainer.Error("Invalid TagIds parameter")
}
- payload.Tags = tags
- if payload.Tags == nil {
- payload.Tags = make([]string, 0)
+ payload.TagIDs = tagIDs
+ if payload.TagIDs == nil {
+ payload.TagIDs = make([]portainer.TagID, 0)
}
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
@@ -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)
}
@@ -187,7 +219,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
AzureCredentials: credentials,
- Tags: payload.Tags,
+ TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
@@ -232,7 +264,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
- Tags: payload.Tags,
+ TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
EdgeKey: edgeKey,
@@ -278,7 +310,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
- Tags: payload.Tags,
+ TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
@@ -322,7 +354,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
- Tags: payload.Tags,
+ TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
@@ -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 b89899d16..57ffacbce 100644
--- a/api/http/handler/endpoints/endpoint_list.go
+++ b/api/http/handler/endpoints/endpoint_list.go
@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
- portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api"
"github.com/portainer/libhttp/request"
@@ -28,6 +28,15 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
+ endpointType, _ := request.RetrieveNumericQueryParameter(r, "type", true)
+
+ 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)
endpointGroups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
@@ -46,12 +55,32 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
+ if endpointIDs != nil {
+ filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
+ }
+
if groupID != 0 {
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
}
if search != "" {
- filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, search)
+ tags, err := handler.TagService.Tags()
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err}
+ }
+ tagsMap := make(map[portainer.TagID]string)
+ for _, tag := range tags {
+ tagsMap[tag.ID] = tag.Name
+ }
+ filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
+ }
+
+ if endpointType != 0 {
+ filteredEndpoints = filterEndpointsByType(filteredEndpoints, portainer.EndpointType(endpointType))
+ }
+
+ if tagIDs != nil {
+ filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
}
filteredEndpointCount := len(filteredEndpoints)
@@ -97,17 +126,17 @@ func filterEndpointsByGroupID(endpoints []portainer.Endpoint, endpointGroupID po
return filteredEndpoints
}
-func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, searchCriteria string) []portainer.Endpoint {
+func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
-
- if endpointMatchSearchCriteria(&endpoint, searchCriteria) {
+ endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
+ if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
continue
}
- if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, searchCriteria) {
+ if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
@@ -115,7 +144,7 @@ func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGro
return filteredEndpoints
}
-func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, searchCriteria string) bool {
+func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
@@ -129,8 +158,7 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, searchCriteria st
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
return true
}
-
- for _, tag := range endpoint.Tags {
+ for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
@@ -139,14 +167,14 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, searchCriteria st
return false
}
-func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, searchCriteria string) bool {
+func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
-
- for _, tag := range group.Tags {
+ tags := convertTagIDsToTags(tagsMap, group.TagIDs)
+ for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
@@ -156,3 +184,106 @@ func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGrou
return false
}
+
+func filterEndpointsByType(endpoints []portainer.Endpoint, endpointType portainer.EndpointType) []portainer.Endpoint {
+ filteredEndpoints := make([]portainer.Endpoint, 0)
+
+ for _, endpoint := range endpoints {
+ if endpoint.Type == endpointType {
+ filteredEndpoints = append(filteredEndpoints, endpoint)
+ }
+ }
+ return filteredEndpoints
+}
+
+func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
+ tags := make([]string, 0)
+ for _, tagID := range tagIDs {
+ tags = append(tags, tagsMap[tagID])
+ }
+ return tags
+}
+
+func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
+ filteredEndpoints := make([]portainer.Endpoint, 0)
+
+ for _, endpoint := range endpoints {
+ endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups)
+ endpointMatched := false
+ if partialMatch {
+ endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs)
+ } else {
+ endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs)
+ }
+
+ if endpointMatched {
+ filteredEndpoints = append(filteredEndpoints, endpoint)
+ }
+ }
+ return filteredEndpoints
+}
+
+func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
+ var endpointGroup portainer.EndpointGroup
+ for _, group := range groups {
+ if group.ID == groupID {
+ endpointGroup = group
+ 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 len(missingTags) == 0
+}
+
+func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
+ filteredEndpoints := make([]portainer.Endpoint, 0)
+
+ idsSet := make(map[portainer.EndpointID]bool)
+ for _, id := range ids {
+ idsSet[id] = true
+ }
+
+ for _, endpoint := range endpoints {
+ if idsSet[endpoint.ID] {
+ filteredEndpoints = append(filteredEndpoints, endpoint)
+ }
+ }
+
+ return filteredEndpoints
+
+}
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 dd87c22f2..2cc521a47 100644
--- a/api/http/handler/endpoints/endpoint_update.go
+++ b/api/http/handler/endpoints/endpoint_update.go
@@ -24,7 +24,7 @@ type endpointUpdatePayload struct {
AzureApplicationID *string
AzureTenantID *string
AzureAuthenticationKey *string
- Tags []string
+ TagIDs []portainer.TagID
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
}
@@ -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
}
- if payload.Tags != nil {
- endpoint.Tags = payload.Tags
+ tagsChanged := false
+ if payload.TagIDs != nil {
+ 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 c655a0eef..bca5dea75 100644
--- a/api/http/handler/endpoints/handler.go
+++ b/api/http/handler/endpoints/handler.go
@@ -29,15 +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
- AuthorizationService *portainer.AuthorizationService
+ Snapshotter portainer.Snapshotter
+ TagService portainer.TagService
}
// NewHandler creates a handler to manage endpoint operations.
@@ -70,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/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go
index 180279217..da7e82751 100644
--- a/api/http/handler/stacks/stack_delete.go
+++ b/api/http/handler/stacks/stack_delete.go
@@ -9,7 +9,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
- "github.com/portainer/portainer/api"
+ portainer "github.com/portainer/portainer/api"
)
// DELETE request on /api/stacks/:id?external=&endpointId=
@@ -21,9 +21,14 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
}
+ securityContext, err := security.RetrieveRestrictedRequestContext(r)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
+ }
+
externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true)
if externalStack {
- return handler.deleteExternalStack(r, w, stackID)
+ return handler.deleteExternalStack(r, w, stackID, securityContext)
}
id, err := strconv.Atoi(stackID)
@@ -68,11 +73,6 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
- securityContext, err := security.RetrieveRestrictedRequestContext(r)
- if err != nil {
- return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
- }
-
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
@@ -106,7 +106,38 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return response.Empty(w)
}
-func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string) *httperror.HandlerError {
+func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError {
+ endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
+ }
+
+ user, err := handler.UserService.User(securityContext.UserID)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
+ }
+
+ rbacExtension, err := handler.ExtensionService.Extension(portainer.RBACExtension)
+ if err != nil && err != portainer.ErrObjectNotFound {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify if RBAC extension is loaded", err}
+ }
+
+ endpointResourceAccess := false
+ _, ok := user.EndpointAuthorizations[portainer.EndpointID(endpointID)][portainer.EndpointResourcesAccess]
+ if ok {
+ endpointResourceAccess = true
+ }
+
+ if rbacExtension != nil {
+ if !securityContext.IsAdmin && !endpointResourceAccess {
+ return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", portainer.ErrUnauthorized}
+ }
+ } else {
+ if !securityContext.IsAdmin {
+ return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", portainer.ErrUnauthorized}
+ }
+ }
+
stack, err := handler.StackService.StackByName(stackName)
if err != nil && err != portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err}
@@ -115,11 +146,6 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit
return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", portainer.ErrStackNotExternal}
}
- endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
- if err != nil {
- return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
- }
-
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go
index 33cb59c9d..21ca61acb 100644
--- a/api/http/handler/tags/handler.go
+++ b/api/http/handler/tags/handler.go
@@ -12,7 +12,12 @@ import (
// Handler is the HTTP handler used to handle tag operations.
type Handler struct {
*mux.Router
- TagService portainer.TagService
+ 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.
@@ -23,7 +28,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
h.Handle("/tags",
bouncer.AdminAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost)
h.Handle("/tags",
- bouncer.AdminAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet)
+ bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet)
h.Handle("/tags/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete)
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 9c9e9d4e3..c2cafe43e 100644
--- a/api/http/handler/tags/tag_delete.go
+++ b/api/http/handler/tags/tag_delete.go
@@ -15,11 +15,126 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid tag identifier route variable", err}
}
+ tagID := portainer.TagID(id)
- err = handler.TagService.DeleteTag(portainer.TagID(id))
+ 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 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)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", 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}
+ }
+
+ tagIdx := findTagIndex(endpointGroup.TagIDs, tagID)
+ if tagIdx != -1 {
+ endpointGroup.TagIDs = removeElement(endpointGroup.TagIDs, tagIdx)
+ err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err}
+ }
+ }
+ }
+
+ 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}
}
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 {
+ return idx
+ }
+ }
+ return -1
+}
+
+func removeElement(arr []portainer.TagID, index int) []portainer.TagID {
+ if index < 0 {
+ return arr
+ }
+ lastTagIdx := len(arr) - 1
+ arr[index] = arr[lastTagIdx]
+ return arr[:lastTagIdx]
+}
diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go
index 4acb7a620..bd8e3f4f7 100644
--- a/api/http/handler/websocket/proxy.go
+++ b/api/http/handler/websocket/proxy.go
@@ -14,7 +14,7 @@ import (
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnel := handler.ReverseTunnelService.GetTunnelDetails(params.endpoint.ID)
- endpointURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", tunnel.Port))
+ endpointURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
if err != nil {
return err
}
diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go
index 365abb2fb..acf0731bd 100644
--- a/api/http/proxy/factory/docker.go
+++ b/api/http/proxy/factory/docker.go
@@ -34,7 +34,7 @@ func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
- endpoint.URL = fmt.Sprintf("http://localhost:%d", tunnel.Port)
+ endpoint.URL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
}
endpointURL, err := url.Parse(endpoint.URL)
diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go
index 83050f79e..2b8e183f9 100644
--- a/api/http/proxy/factory/docker/access_control.go
+++ b/api/http/proxy/factory/docker/access_control.go
@@ -158,7 +158,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
- if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess || portainer.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
+ if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess || (resourceControl != nil && portainer.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) {
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go
index 961a98223..c511948fa 100644
--- a/api/http/proxy/factory/docker/transport.go
+++ b/api/http/proxy/factory/docker/transport.go
@@ -171,11 +171,13 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response,
switch {
case strings.HasPrefix(requestPath, "/browse"):
+ // host file browser request
volumeIDParameter, found := r.URL.Query()["volumeID"]
if !found || len(volumeIDParameter) < 1 {
return transport.administratorOperation(r)
}
+ // volume browser request
return transport.restrictedResourceOperation(r, volumeIDParameter[0], portainer.VolumeResourceControl, true)
}
@@ -273,7 +275,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/volumes/create":
- return transport.decorateGenericResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl)
+ return transport.decorateVolumeResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl)
case "/volumes/prune":
return transport.administratorOperation(request)
@@ -443,10 +445,16 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
return nil, err
}
- // Return access denied for all roles except endpoint-administrator
- _, userCanBrowse := user.EndpointAuthorizations[transport.endpoint.ID][portainer.OperationDockerAgentBrowseList]
- if rbacExtension != nil && !settings.AllowVolumeBrowserForRegularUsers && !userCanBrowse {
- return responseutils.WriteAccessDeniedResponse()
+ if !settings.AllowVolumeBrowserForRegularUsers {
+ if rbacExtension == nil {
+ return responseutils.WriteAccessDeniedResponse()
+ }
+
+ // Return access denied for all roles except endpoint-administrator
+ _, userCanBrowse := user.EndpointAuthorizations[transport.endpoint.ID][portainer.OperationDockerAgentBrowseList]
+ if !userCanBrowse {
+ return responseutils.WriteAccessDeniedResponse()
+ }
}
}
diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go
index 49512852e..5727d0157 100644
--- a/api/http/proxy/factory/docker/volumes.go
+++ b/api/http/proxy/factory/docker/volumes.go
@@ -2,12 +2,14 @@ package docker
import (
"context"
+ "errors"
"net/http"
"github.com/docker/docker/client"
- "github.com/portainer/portainer/api"
+ portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
+ "github.com/portainer/portainer/api/http/security"
)
const (
@@ -87,3 +89,40 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec
func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} {
return responseutils.GetJSONObject(responseObject, "Labels")
}
+
+func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
+ tokenData, err := security.RetrieveTokenData(request)
+ if err != nil {
+ return nil, err
+ }
+
+ volumeID := request.Header.Get("X-Portainer-VolumeName")
+
+ if volumeID != "" {
+ cli := transport.dockerClient
+ agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
+ if agentTargetHeader != "" {
+ dockerClient, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader)
+ if err != nil {
+ return nil, err
+ }
+ defer dockerClient.Close()
+ cli = dockerClient
+ }
+
+ _, err = cli.VolumeInspect(context.Background(), volumeID)
+ if err == nil {
+ return nil, errors.New("a volume with the same name already exists")
+ }
+ }
+
+ response, err := transport.executeDockerRequest(request)
+ if err != nil {
+ return response, err
+ }
+
+ if response.StatusCode == http.StatusCreated {
+ err = transport.decorateGenericResourceCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID)
+ }
+ return response, err
+}
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 066bc7bef..f1c98ee5f 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -3,11 +3,15 @@ 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"
- "github.com/portainer/portainer/api"
+ portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/handler"
"github.com/portainer/portainer/api/http/handler/auth"
@@ -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,6 +261,11 @@ func (server *Server) Start() error {
stackHandler.ExtensionService = server.ExtensionService
var tagHandler = tags.NewHandler(requestBouncer)
+ 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)
@@ -266,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/libcompose/compose_stack.go b/api/libcompose/compose_stack.go
index 30322d7d8..d3bf546c3 100644
--- a/api/libcompose/compose_stack.go
+++ b/api/libcompose/compose_stack.go
@@ -39,7 +39,7 @@ func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID)
- endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port)
+ endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
}
clientOpts := client.Options{
diff --git a/api/portainer.go b/api/portainer.go
index e97230a0e..9b6149555 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -3,10 +3,33 @@ package portainer
import "time"
type (
- // Pair defines a key/value string pair
- Pair struct {
- Name string `json:"name"`
- Value string `json:"value"`
+ // AccessPolicy represent a policy that can be associated to a user or team
+ AccessPolicy struct {
+ RoleID RoleID `json:"RoleId"`
+ }
+
+ // APIOperationAuthorizationRequest represent an request for the authorization to execute an API operation
+ APIOperationAuthorizationRequest struct {
+ Path string
+ Method string
+ Authorizations Authorizations
+ }
+
+ // AuthenticationMethod represents the authentication method used to authenticate a user
+ AuthenticationMethod int
+
+ // Authorization represents an authorization associated to an operation
+ Authorization string
+
+ // Authorizations represents a set of authorizations associated to a role
+ Authorizations map[Authorization]bool
+
+ // AzureCredentials represents the credentials used to connect to an Azure
+ // environment.
+ AzureCredentials struct {
+ ApplicationID string `json:"ApplicationID"`
+ TenantID string `json:"TenantID"`
+ AuthenticationKey string `json:"AuthenticationKey"`
}
// CLIFlags represents the available flags on the CLI
@@ -39,13 +62,210 @@ type (
SnapshotInterval *string
}
- // Status represents the application status
- Status struct {
- Authentication bool `json:"Authentication"`
- EndpointManagement bool `json:"EndpointManagement"`
- Snapshot bool `json:"Snapshot"`
- Analytics bool `json:"Analytics"`
- Version string `json:"Version"`
+ // CLIService represents a service for managing CLI
+ CLIService interface {
+ ParseFlags(version string) (*CLIFlags, error)
+ ValidateFlags(flags *CLIFlags) error
+ }
+
+ // DataStore defines the interface to manage the data
+ DataStore interface {
+ Open() error
+ Init() error
+ Close() error
+ MigrateData() error
+ }
+
+ // DockerHub represents all the required information to connect and use the
+ // Docker Hub
+ DockerHub struct {
+ Authentication bool `json:"Authentication"`
+ Username string `json:"Username"`
+ 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"`
+ CronExpression string `json:"CronExpression"`
+ Script string `json:"Script"`
+ Version int `json:"Version"`
+ 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 {
+ ID EndpointID `json:"Id"`
+ Name string `json:"Name"`
+ Type EndpointType `json:"Type"`
+ URL string `json:"URL"`
+ GroupID EndpointGroupID `json:"GroupId"`
+ PublicURL string `json:"PublicURL"`
+ TLSConfig TLSConfiguration `json:"TLSConfig"`
+ Extensions []EndpointExtension `json:"Extensions"`
+ AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
+ TagIDs []TagID `json:"TagIds"`
+ Status EndpointStatus `json:"Status"`
+ Snapshots []Snapshot `json:"Snapshots"`
+ UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
+ TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
+ EdgeID string `json:"EdgeID,omitempty"`
+ EdgeKey string `json:"EdgeKey"`
+ // Deprecated fields
+ // Deprecated in DBVersion == 4
+ TLS bool `json:"TLS,omitempty"`
+ TLSCACertPath string `json:"TLSCACert,omitempty"`
+ TLSCertPath string `json:"TLSCert,omitempty"`
+ TLSKeyPath string `json:"TLSKey,omitempty"`
+
+ // Deprecated in DBVersion == 18
+ AuthorizedUsers []UserID `json:"AuthorizedUsers"`
+ AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
+
+ // Deprecated in DBVersion == 22
+ Tags []string `json:"Tags"`
+ }
+
+ // EndpointAuthorizations represents the authorizations associated to a set of endpoints
+ EndpointAuthorizations map[EndpointID]Authorizations
+
+ // EndpointExtension represents a deprecated form of Portainer extension
+ // TODO: legacy extension management
+ EndpointExtension struct {
+ Type EndpointExtensionType `json:"Type"`
+ URL string `json:"URL"`
+ }
+
+ // EndpointExtensionType represents the type of an endpoint extension. Only
+ // one extension of each type can be associated to an endpoint
+ EndpointExtensionType int
+
+ // EndpointGroup represents a group of endpoints
+ EndpointGroup struct {
+ ID EndpointGroupID `json:"Id"`
+ Name string `json:"Name"`
+ Description string `json:"Description"`
+ UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
+ TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
+ TagIDs []TagID `json:"TagIds"`
+
+ // Deprecated fields
+ Labels []Pair `json:"Labels"`
+
+ // Deprecated in DBVersion == 18
+ AuthorizedUsers []UserID `json:"AuthorizedUsers"`
+ AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
+
+ // Deprecated in DBVersion == 22
+ Tags []string `json:"Tags"`
+ }
+
+ // EndpointGroupID represents an endpoint group identifier
+ EndpointGroupID int
+
+ // EndpointID represents an endpoint identifier
+ EndpointID int
+
+ // EndpointStatus represents the status of an endpoint
+ EndpointStatus int
+
+ // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file
+ EndpointSyncJob struct{}
+
+ // 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"`
+ Enabled bool `json:"Enabled"`
+ Name string `json:"Name,omitempty"`
+ ShortDescription string `json:"ShortDescription,omitempty"`
+ Description string `json:"Description,omitempty"`
+ DescriptionURL string `json:"DescriptionURL,omitempty"`
+ Price string `json:"Price,omitempty"`
+ PriceDescription string `json:"PriceDescription,omitempty"`
+ Deal bool `json:"Deal,omitempty"`
+ Available bool `json:"Available,omitempty"`
+ License LicenseInformation `json:"License,omitempty"`
+ Version string `json:"Version"`
+ UpdateAvailable bool `json:"UpdateAvailable"`
+ ShopURL string `json:"ShopURL,omitempty"`
+ Images []string `json:"Images,omitempty"`
+ Logo string `json:"Logo,omitempty"`
+ }
+
+ // ExtensionID represents a extension identifier
+ ExtensionID int
+
+ // GitlabRegistryData represents data required for gitlab registry to work
+ GitlabRegistryData struct {
+ ProjectID int `json:"ProjectId"`
+ InstanceURL string `json:"InstanceURL"`
+ ProjectPath string `json:"ProjectPath"`
+ }
+
+ // JobType represents a job type
+ JobType int
+
+ // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
+ LDAPGroupSearchSettings struct {
+ GroupBaseDN string `json:"GroupBaseDN"`
+ GroupFilter string `json:"GroupFilter"`
+ GroupAttribute string `json:"GroupAttribute"`
+ }
+
+ // LDAPSearchSettings represents settings used to search for users in a LDAP server
+ LDAPSearchSettings struct {
+ BaseDN string `json:"BaseDN"`
+ Filter string `json:"Filter"`
+ UserNameAttribute string `json:"UserNameAttribute"`
}
// LDAPSettings represents the settings used to connect to a LDAP server
@@ -61,6 +281,17 @@ type (
AutoCreateUsers bool `json:"AutoCreateUsers"`
}
+ // LicenseInformation represents information about an extension license
+ LicenseInformation struct {
+ LicenseKey string `json:"LicenseKey,omitempty"`
+ Company string `json:"Company,omitempty"`
+ Expiration string `json:"Expiration,omitempty"`
+ Valid bool `json:"Valid,omitempty"`
+ }
+
+ // MembershipRole represents the role of a user within a team
+ MembershipRole int
+
// OAuthSettings represents the settings used to authorize with an authorization server
OAuthSettings struct {
ClientID string `json:"ClientID"`
@@ -75,129 +306,10 @@ type (
DefaultTeamID TeamID `json:"DefaultTeamID"`
}
- // TLSConfiguration represents a TLS configuration
- TLSConfiguration struct {
- TLS bool `json:"TLS"`
- TLSSkipVerify bool `json:"TLSSkipVerify"`
- TLSCACertPath string `json:"TLSCACert,omitempty"`
- TLSCertPath string `json:"TLSCert,omitempty"`
- TLSKeyPath string `json:"TLSKey,omitempty"`
- }
-
- // LDAPSearchSettings represents settings used to search for users in a LDAP server
- LDAPSearchSettings struct {
- BaseDN string `json:"BaseDN"`
- Filter string `json:"Filter"`
- UserNameAttribute string `json:"UserNameAttribute"`
- }
-
- // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
- LDAPGroupSearchSettings struct {
- GroupBaseDN string `json:"GroupBaseDN"`
- GroupFilter string `json:"GroupFilter"`
- GroupAttribute string `json:"GroupAttribute"`
- }
-
- // Settings represents the application settings
- Settings struct {
- LogoURL string `json:"LogoURL"`
- BlackListedLabels []Pair `json:"BlackListedLabels"`
- AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
- LDAPSettings LDAPSettings `json:"LDAPSettings"`
- OAuthSettings OAuthSettings `json:"OAuthSettings"`
- AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
- AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
- AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
- SnapshotInterval string `json:"SnapshotInterval"`
- TemplatesURL string `json:"TemplatesURL"`
- EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
- EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
-
- // Deprecated fields
- DisplayDonationHeader bool
- DisplayExternalContributors bool
- }
-
- // User represents a user account
- User struct {
- ID UserID `json:"Id"`
- Username string `json:"Username"`
- Password string `json:"Password,omitempty"`
- Role UserRole `json:"Role"`
- PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"`
- EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"`
- }
-
- // UserID represents a user identifier
- UserID int
-
- // UserRole represents the role of a user. It can be either an administrator
- // or a regular user
- UserRole int
-
- // AuthenticationMethod represents the authentication method used to authenticate a user
- AuthenticationMethod int
-
- // Team represents a list of user accounts
- Team struct {
- ID TeamID `json:"Id"`
- Name string `json:"Name"`
- }
-
- // TeamID represents a team identifier
- TeamID int
-
- // TeamMembership represents a membership association between a user and a team
- TeamMembership struct {
- ID TeamMembershipID `json:"Id"`
- UserID UserID `json:"UserID"`
- TeamID TeamID `json:"TeamID"`
- Role MembershipRole `json:"Role"`
- }
-
- // TeamMembershipID represents a team membership identifier
- TeamMembershipID int
-
- // MembershipRole represents the role of a user within a team
- MembershipRole int
-
- // TokenData represents the data embedded in a JWT token
- TokenData struct {
- ID UserID
- Username string
- Role UserRole
- }
-
- // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
- StackID int
-
- // StackType represents the type of the stack (compose v2, stack deploy v3)
- StackType int
-
- // Stack represents a Docker stack created via docker stack deploy
- Stack struct {
- ID StackID `json:"Id"`
- Name string `json:"Name"`
- Type StackType `json:"Type"`
- EndpointID EndpointID `json:"EndpointId"`
- SwarmID string `json:"SwarmId"`
- EntryPoint string `json:"EntryPoint"`
- Env []Pair `json:"Env"`
- ResourceControl *ResourceControl `json:"ResourceControl"`
- ProjectPath string
- }
-
- // RegistryID represents a registry identifier
- RegistryID int
-
- // RegistryType represents a type of registry
- RegistryType int
-
- // GitlabRegistryData represents data required for gitlab registry to work
- GitlabRegistryData struct {
- ProjectID int `json:"ProjectId"`
- InstanceURL string `json:"InstanceURL"`
- ProjectPath string `json:"ProjectPath"`
+ // Pair defines a key/value string pair
+ Pair struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
}
// Registry represents a Docker registry with all the info required
@@ -221,6 +333,9 @@ type (
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
}
+ // RegistryID represents a registry identifier
+ RegistryID int
+
// RegistryManagementConfiguration represents a configuration that can be used to query
// the registry API via the registry management extension.
RegistryManagementConfiguration struct {
@@ -231,72 +346,35 @@ type (
TLSConfig TLSConfiguration `json:"TLSConfig"`
}
- // DockerHub represents all the required information to connect and use the
- // Docker Hub
- DockerHub struct {
- Authentication bool `json:"Authentication"`
- Username string `json:"Username"`
- Password string `json:"Password,omitempty"`
- }
+ // RegistryType represents a type of registry
+ RegistryType int
- // EndpointID represents an endpoint identifier
- EndpointID int
+ // ResourceAccessLevel represents the level of control associated to a resource
+ ResourceAccessLevel int
- // EndpointType represents the type of an endpoint
- EndpointType int
+ // ResourceControl represent a reference to a Docker resource with specific access controls
+ ResourceControl struct {
+ ID ResourceControlID `json:"Id"`
+ ResourceID string `json:"ResourceId"`
+ SubResourceIDs []string `json:"SubResourceIds"`
+ Type ResourceControlType `json:"Type"`
+ UserAccesses []UserResourceAccess `json:"UserAccesses"`
+ TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
+ Public bool `json:"Public"`
+ AdministratorsOnly bool `json:"AdministratorsOnly"`
+ System bool `json:"System"`
- // EndpointStatus represents the status of an endpoint
- EndpointStatus int
-
- // Endpoint represents a Docker endpoint with all the info required
- // to connect to it
- Endpoint struct {
- ID EndpointID `json:"Id"`
- Name string `json:"Name"`
- Type EndpointType `json:"Type"`
- URL string `json:"URL"`
- GroupID EndpointGroupID `json:"GroupId"`
- PublicURL string `json:"PublicURL"`
- TLSConfig TLSConfiguration `json:"TLSConfig"`
- Extensions []EndpointExtension `json:"Extensions"`
- AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
- Tags []string `json:"Tags"`
- Status EndpointStatus `json:"Status"`
- Snapshots []Snapshot `json:"Snapshots"`
- UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
- TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
- EdgeID string `json:"EdgeID,omitempty"`
- EdgeKey string `json:"EdgeKey"`
// Deprecated fields
- // Deprecated in DBVersion == 4
- TLS bool `json:"TLS,omitempty"`
- TLSCACertPath string `json:"TLSCACert,omitempty"`
- TLSCertPath string `json:"TLSCert,omitempty"`
- TLSKeyPath string `json:"TLSKey,omitempty"`
-
- // Deprecated in DBVersion == 18
- AuthorizedUsers []UserID `json:"AuthorizedUsers"`
- AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
+ // Deprecated in DBVersion == 2
+ OwnerID UserID `json:"OwnerId,omitempty"`
+ AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
}
- // Authorization represents an authorization associated to an operation
- Authorization string
+ // ResourceControlID represents a resource control identifier
+ ResourceControlID int
- // Authorizations represents a set of authorizations associated to a role
- Authorizations map[Authorization]bool
-
- // EndpointAuthorizations represents the authorizations associated to a set of endpoints
- EndpointAuthorizations map[EndpointID]Authorizations
-
- // APIOperationAuthorizationRequest represent an request for the authorization to execute an API operation
- APIOperationAuthorizationRequest struct {
- Path string
- Method string
- Authorizations Authorizations
- }
-
- // RoleID represents a role identifier
- RoleID int
+ // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...)
+ ResourceControlType int
// Role represents a set of authorizations that can be associated to a user or
// to a team.
@@ -308,36 +386,8 @@ type (
Priority int `json:"Priority"`
}
- // AccessPolicy represent a policy that can be associated to a user or team
- AccessPolicy struct {
- RoleID RoleID `json:"RoleId"`
- }
-
- // UserAccessPolicies represent the association of an access policy and a user
- UserAccessPolicies map[UserID]AccessPolicy
- // TeamAccessPolicies represent the association of an access policy and a team
- TeamAccessPolicies map[TeamID]AccessPolicy
-
- // ScheduleID represents a schedule identifier.
- ScheduleID int
-
- // JobType represents a job type
- JobType int
-
- // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
- ScriptExecutionJob struct {
- Endpoints []EndpointID
- Image string
- ScriptPath string
- RetryCount int
- RetryInterval int
- }
-
- // SnapshotJob represents a scheduled job that can create endpoint snapshots
- SnapshotJob struct{}
-
- // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file
- EndpointSyncJob struct{}
+ // RoleID represents a role identifier
+ RoleID int
// Schedule represents a scheduled job.
// It only contains a pointer to one of the JobRunner implementations
@@ -356,36 +406,37 @@ type (
EndpointSyncJob *EndpointSyncJob
}
- // EdgeSchedule represents a scheduled job that can run on Edge environments.
- EdgeSchedule struct {
- ID ScheduleID `json:"Id"`
- CronExpression string `json:"CronExpression"`
- Script string `json:"Script"`
- Version int `json:"Version"`
- Endpoints []EndpointID `json:"Endpoints"`
+ // ScheduleID represents a schedule identifier.
+ ScheduleID int
+
+ // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
+ ScriptExecutionJob struct {
+ Endpoints []EndpointID
+ Image string
+ ScriptPath string
+ RetryCount int
+ RetryInterval int
}
- // WebhookID represents a webhook identifier.
- WebhookID int
+ // Settings represents the application settings
+ Settings struct {
+ LogoURL string `json:"LogoURL"`
+ BlackListedLabels []Pair `json:"BlackListedLabels"`
+ AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
+ LDAPSettings LDAPSettings `json:"LDAPSettings"`
+ OAuthSettings OAuthSettings `json:"OAuthSettings"`
+ AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
+ AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
+ AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
+ SnapshotInterval string `json:"SnapshotInterval"`
+ TemplatesURL string `json:"TemplatesURL"`
+ EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
+ EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
+ EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
- // WebhookType represents the type of resource a webhook is related to
- WebhookType int
-
- // Webhook represents a url webhook that can be used to update a service
- Webhook struct {
- ID WebhookID `json:"Id"`
- Token string `json:"Token"`
- ResourceID string `json:"ResourceId"`
- EndpointID EndpointID `json:"EndpointId"`
- WebhookType WebhookType `json:"Type"`
- }
-
- // AzureCredentials represents the credentials used to connect to an Azure
- // environment.
- AzureCredentials struct {
- ApplicationID string `json:"ApplicationID"`
- TenantID string `json:"TenantID"`
- AuthenticationKey string `json:"AuthenticationKey"`
+ // Deprecated fields
+ DisplayDonationHeader bool
+ DisplayExternalContributors bool
}
// Snapshot represents a snapshot of a specific endpoint at a specific time
@@ -406,6 +457,9 @@ type (
SnapshotRaw SnapshotRaw `json:"SnapshotRaw"`
}
+ // SnapshotJob represents a scheduled job that can create endpoint snapshots
+ SnapshotJob struct{}
+
// SnapshotRaw represents all the information related to a snapshot as returned by the Docker API
SnapshotRaw struct {
Containers interface{} `json:"Containers"`
@@ -416,88 +470,74 @@ type (
Version interface{} `json:"Version"`
}
- // EndpointGroupID represents an endpoint group identifier
- EndpointGroupID int
-
- // EndpointGroup represents a group of endpoints
- EndpointGroup struct {
- ID EndpointGroupID `json:"Id"`
- Name string `json:"Name"`
- Description string `json:"Description"`
- UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
- TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
- Tags []string `json:"Tags"`
-
- // Deprecated fields
- Labels []Pair `json:"Labels"`
-
- // Deprecated in DBVersion == 18
- AuthorizedUsers []UserID `json:"AuthorizedUsers"`
- AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
+ // Stack represents a Docker stack created via docker stack deploy
+ Stack struct {
+ ID StackID `json:"Id"`
+ Name string `json:"Name"`
+ Type StackType `json:"Type"`
+ EndpointID EndpointID `json:"EndpointId"`
+ SwarmID string `json:"SwarmId"`
+ EntryPoint string `json:"EntryPoint"`
+ Env []Pair `json:"Env"`
+ ResourceControl *ResourceControl `json:"ResourceControl"`
+ ProjectPath string
}
- // EndpointExtension represents a deprecated form of Portainer extension
- // TODO: legacy extension management
- EndpointExtension struct {
- Type EndpointExtensionType `json:"Type"`
- URL string `json:"URL"`
+ // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
+ StackID int
+
+ // StackType represents the type of the stack (compose v2, stack deploy v3)
+ StackType int
+
+ // Status represents the application status
+ Status struct {
+ Authentication bool `json:"Authentication"`
+ EndpointManagement bool `json:"EndpointManagement"`
+ Snapshot bool `json:"Snapshot"`
+ Analytics bool `json:"Analytics"`
+ Version string `json:"Version"`
}
- // EndpointExtensionType represents the type of an endpoint extension. Only
- // one extension of each type can be associated to an endpoint
- EndpointExtensionType int
-
- // ResourceControlID represents a resource control identifier
- ResourceControlID int
-
- // ResourceControl represent a reference to a Docker resource with specific access controls
- ResourceControl struct {
- ID ResourceControlID `json:"Id"`
- ResourceID string `json:"ResourceId"`
- SubResourceIDs []string `json:"SubResourceIds"`
- Type ResourceControlType `json:"Type"`
- UserAccesses []UserResourceAccess `json:"UserAccesses"`
- TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
- Public bool `json:"Public"`
- AdministratorsOnly bool `json:"AdministratorsOnly"`
- System bool `json:"System"`
-
- // Deprecated fields
- // Deprecated in DBVersion == 2
- OwnerID UserID `json:"OwnerId,omitempty"`
- AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
+ // Tag represents a tag that can be associated to a resource
+ Tag struct {
+ ID TagID
+ Name string `json:"Name"`
+ Endpoints map[EndpointID]bool `json:"Endpoints"`
+ EndpointGroups map[EndpointGroupID]bool `json:"EndpointGroups"`
}
- // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...)
- ResourceControlType int
+ // TagID represents a tag identifier
+ TagID int
- // UserResourceAccess represents the level of control on a resource for a specific user
- UserResourceAccess struct {
- UserID UserID `json:"UserId"`
- AccessLevel ResourceAccessLevel `json:"AccessLevel"`
+ // Team represents a list of user accounts
+ Team struct {
+ ID TeamID `json:"Id"`
+ Name string `json:"Name"`
}
+ // TeamAccessPolicies represent the association of an access policy and a team
+ TeamAccessPolicies map[TeamID]AccessPolicy
+
+ // TeamID represents a team identifier
+ TeamID int
+
+ // TeamMembership represents a membership association between a user and a team
+ TeamMembership struct {
+ ID TeamMembershipID `json:"Id"`
+ UserID UserID `json:"UserID"`
+ TeamID TeamID `json:"TeamID"`
+ Role MembershipRole `json:"Role"`
+ }
+
+ // TeamMembershipID represents a team membership identifier
+ TeamMembershipID int
+
// TeamResourceAccess represents the level of control on a resource for a specific team
TeamResourceAccess struct {
TeamID TeamID `json:"TeamId"`
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
- // TagID represents a tag identifier
- TagID int
-
- // Tag represents a tag that can be associated to a resource
- Tag struct {
- ID TagID
- Name string `json:"Name"`
- }
-
- // TemplateID represents a template identifier
- TemplateID int
-
- // TemplateType represents the type of a template
- TemplateType int
-
// Template represents an application template
Template struct {
// Mandatory container/stack fields
@@ -513,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"`
@@ -544,19 +587,6 @@ type (
Select []TemplateEnvSelect `json:"select,omitempty"`
}
- // TemplateVolume represents a template volume configuration
- TemplateVolume struct {
- Container string `json:"container"`
- Bind string `json:"bind,omitempty"`
- ReadOnly bool `json:"readonly,omitempty"`
- }
-
- // TemplateRepository represents the git repository configuration for a template
- TemplateRepository struct {
- URL string `json:"url"`
- StackFile string `json:"stackfile"`
- }
-
// TemplateEnvSelect represents text/value pair that will be displayed as a choice for the
// template user
TemplateEnvSelect struct {
@@ -565,42 +595,43 @@ type (
Default bool `json:"default"`
}
- // ResourceAccessLevel represents the level of control associated to a resource
- ResourceAccessLevel int
+ // TemplateID represents a template identifier
+ TemplateID int
+
+ // TemplateRepository represents the git repository configuration for a template
+ TemplateRepository struct {
+ URL string `json:"url"`
+ StackFile string `json:"stackfile"`
+ }
+
+ // TemplateType represents the type of a template
+ TemplateType int
+
+ // TemplateVolume represents a template volume configuration
+ TemplateVolume struct {
+ Container string `json:"container"`
+ Bind string `json:"bind,omitempty"`
+ ReadOnly bool `json:"readonly,omitempty"`
+ }
+
+ // TLSConfiguration represents a TLS configuration
+ TLSConfiguration struct {
+ TLS bool `json:"TLS"`
+ TLSSkipVerify bool `json:"TLSSkipVerify"`
+ TLSCACertPath string `json:"TLSCACert,omitempty"`
+ TLSCertPath string `json:"TLSCert,omitempty"`
+ TLSKeyPath string `json:"TLSKey,omitempty"`
+ }
// TLSFileType represents a type of TLS file required to connect to a Docker endpoint.
// It can be either a TLS CA file, a TLS certificate file or a TLS key file
TLSFileType int
- // ExtensionID represents a extension identifier
- ExtensionID int
-
- // Extension represents a Portainer extension
- Extension struct {
- ID ExtensionID `json:"Id"`
- Enabled bool `json:"Enabled"`
- Name string `json:"Name,omitempty"`
- ShortDescription string `json:"ShortDescription,omitempty"`
- Description string `json:"Description,omitempty"`
- DescriptionURL string `json:"DescriptionURL,omitempty"`
- Price string `json:"Price,omitempty"`
- PriceDescription string `json:"PriceDescription,omitempty"`
- Deal bool `json:"Deal,omitempty"`
- Available bool `json:"Available,omitempty"`
- License LicenseInformation `json:"License,omitempty"`
- Version string `json:"Version"`
- UpdateAvailable bool `json:"UpdateAvailable"`
- ShopURL string `json:"ShopURL,omitempty"`
- Images []string `json:"Images,omitempty"`
- Logo string `json:"Logo,omitempty"`
- }
-
- // LicenseInformation represents information about an extension license
- LicenseInformation struct {
- LicenseKey string `json:"LicenseKey,omitempty"`
- Company string `json:"Company,omitempty"`
- Expiration string `json:"Expiration,omitempty"`
- Valid bool `json:"Valid,omitempty"`
+ // TokenData represents the data embedded in a JWT token
+ TokenData struct {
+ ID UserID
+ Username string
+ Role UserRole
}
// TunnelDetails represents information associated to a tunnel
@@ -617,18 +648,238 @@ type (
PrivateKeySeed string `json:"PrivateKeySeed"`
}
- // CLIService represents a service for managing CLI
- CLIService interface {
- ParseFlags(version string) (*CLIFlags, error)
- ValidateFlags(flags *CLIFlags) error
+ // User represents a user account
+ User struct {
+ ID UserID `json:"Id"`
+ Username string `json:"Username"`
+ Password string `json:"Password,omitempty"`
+ Role UserRole `json:"Role"`
+ PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"`
+ EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"`
}
- // DataStore defines the interface to manage the data
- DataStore interface {
- Open() error
- Init() error
- Close() error
- MigrateData() error
+ // UserAccessPolicies represent the association of an access policy and a user
+ UserAccessPolicies map[UserID]AccessPolicy
+
+ // UserID represents a user identifier
+ UserID int
+
+ // UserResourceAccess represents the level of control on a resource for a specific user
+ UserResourceAccess struct {
+ UserID UserID `json:"UserId"`
+ AccessLevel ResourceAccessLevel `json:"AccessLevel"`
+ }
+
+ // UserRole represents the role of a user. It can be either an administrator
+ // or a regular user
+ UserRole int
+
+ // Webhook represents a url webhook that can be used to update a service
+ Webhook struct {
+ ID WebhookID `json:"Id"`
+ Token string `json:"Token"`
+ ResourceID string `json:"ResourceId"`
+ EndpointID EndpointID `json:"EndpointId"`
+ WebhookType WebhookType `json:"Type"`
+ }
+
+ // WebhookID represents a webhook identifier.
+ WebhookID int
+
+ // WebhookType represents the type of resource a webhook is related to
+ WebhookType int
+
+ // ComposeStackManager represents a service to manage Compose stacks
+ ComposeStackManager interface {
+ Up(stack *Stack, endpoint *Endpoint) error
+ Down(stack *Stack, endpoint *Endpoint) error
+ }
+
+ // CryptoService represents a service for encrypting/hashing data
+ CryptoService interface {
+ Hash(data string) (string, error)
+ CompareHashAndData(hash string, data string) error
+ }
+
+ // DigitalSignatureService represents a service to manage digital signatures
+ DigitalSignatureService interface {
+ ParseKeyPair(private, public []byte) error
+ GenerateKeyPair() ([]byte, []byte, error)
+ EncodedPublicKey() string
+ PEMHeaders() (string, string)
+ CreateSignature(message string) (string, error)
+ }
+
+ // DockerHubService represents a service for managing the DockerHub object
+ DockerHubService interface {
+ DockerHub() (*DockerHub, error)
+ UpdateDockerHub(registry *DockerHub) error
+ }
+
+ // EndpointService represents a service for managing endpoint data
+ EndpointService interface {
+ Endpoint(ID EndpointID) (*Endpoint, error)
+ Endpoints() ([]Endpoint, error)
+ CreateEndpoint(endpoint *Endpoint) error
+ UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
+ DeleteEndpoint(ID EndpointID) error
+ Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
+ GetNextIdentifier() int
+ }
+
+ // EndpointGroupService represents a service for managing endpoint group data
+ EndpointGroupService interface {
+ EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
+ EndpointGroups() ([]EndpointGroup, error)
+ CreateEndpointGroup(group *EndpointGroup) error
+ UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
+ 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)
+ InstallExtension(extension *Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error
+ EnableExtension(extension *Extension, licenseKey string) error
+ DisableExtension(extension *Extension) error
+ UpdateExtension(extension *Extension, version string) error
+ StartExtensions() error
+ }
+
+ // ExtensionService represents a service for managing extension data
+ ExtensionService interface {
+ Extension(ID ExtensionID) (*Extension, error)
+ Extensions() ([]Extension, error)
+ Persist(extension *Extension) error
+ DeleteExtension(ID ExtensionID) error
+ }
+
+ // FileService represents a service for managing files
+ FileService interface {
+ GetFileContent(filePath string) ([]byte, error)
+ Rename(oldPath, newPath string) error
+ RemoveDirectory(directoryPath string) error
+ StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
+ GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
+ DeleteTLSFile(folder string, fileType TLSFileType) error
+ DeleteTLSFiles(folder string) error
+ GetStackProjectPath(stackIdentifier string) string
+ 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
+ LoadKeyPair() ([]byte, []byte, error)
+ WriteJSONToFile(path string, content interface{}) error
+ FileExists(path string) (bool, error)
+ StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
+ GetScheduleFolder(identifier string) string
+ ExtractExtensionArchive(data []byte) error
+ GetBinaryFolder() string
+ }
+
+ // GitService represents a service for managing Git
+ GitService interface {
+ ClonePublicRepository(repositoryURL, referenceName string, destination string) error
+ ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
+ }
+
+ // JobRunner represents a service that can be used to run a job
+ JobRunner interface {
+ Run()
+ GetSchedule() *Schedule
+ }
+
+ // JobScheduler represents a service to run jobs on a periodic basis
+ JobScheduler interface {
+ ScheduleJob(runner JobRunner) error
+ UpdateJobSchedule(runner JobRunner) error
+ UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error
+ UnscheduleJob(ID ScheduleID)
+ Start()
+ }
+
+ // JobService represents a service to manage job execution on hosts
+ JobService interface {
+ ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
+ }
+
+ // JWTService represents a service for managing JWT tokens
+ JWTService interface {
+ GenerateToken(data *TokenData) (string, error)
+ ParseAndVerifyToken(token string) (*TokenData, error)
+ }
+
+ // LDAPService represents a service used to authenticate users against a LDAP/AD
+ LDAPService interface {
+ AuthenticateUser(username, password string, settings *LDAPSettings) error
+ TestConnectivity(settings *LDAPSettings) error
+ GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
+ }
+
+ // RegistryService represents a service for managing registry data
+ RegistryService interface {
+ Registry(ID RegistryID) (*Registry, error)
+ Registries() ([]Registry, error)
+ CreateRegistry(registry *Registry) error
+ UpdateRegistry(ID RegistryID, registry *Registry) error
+ DeleteRegistry(ID RegistryID) error
+ }
+
+ // ResourceControlService represents a service for managing resource control data
+ ResourceControlService interface {
+ ResourceControl(ID ResourceControlID) (*ResourceControl, error)
+ ResourceControlByResourceIDAndType(resourceID string, resourceType ResourceControlType) (*ResourceControl, error)
+ ResourceControls() ([]ResourceControl, error)
+ CreateResourceControl(rc *ResourceControl) error
+ UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error
+ DeleteResourceControl(ID ResourceControlID) error
+ }
+
+ // ReverseTunnelService represensts a service used to manage reverse tunnel connections.
+ ReverseTunnelService interface {
+ StartTunnelServer(addr, port string, snapshotter Snapshotter) error
+ GenerateEdgeKey(url, host string, endpointIdentifier int) string
+ SetTunnelStatusToActive(endpointID EndpointID)
+ SetTunnelStatusToRequired(endpointID EndpointID) error
+ SetTunnelStatusToIdle(endpointID EndpointID)
+ GetTunnelDetails(endpointID EndpointID) *TunnelDetails
+ AddSchedule(endpointID EndpointID, schedule *EdgeSchedule)
+ RemoveSchedule(scheduleID ScheduleID)
+ }
+
+ // RoleService represents a service for managing user roles
+ RoleService interface {
+ Role(ID RoleID) (*Role, error)
+ Roles() ([]Role, error)
+ CreateRole(role *Role) error
+ UpdateRole(ID RoleID, role *Role) error
+ }
+
+ // ScheduleService represents a service for managing schedule data
+ ScheduleService interface {
+ Schedule(ID ScheduleID) (*Schedule, error)
+ Schedules() ([]Schedule, error)
+ SchedulesByJobType(jobType JobType) ([]Schedule, error)
+ CreateSchedule(schedule *Schedule) error
+ UpdateSchedule(ID ScheduleID, schedule *Schedule) error
+ DeleteSchedule(ID ScheduleID) error
+ GetNextIdentifier() int
+ }
+
+ // SettingsService represents a service for managing application settings
+ SettingsService interface {
+ Settings() (*Settings, error)
+ UpdateSettings(settings *Settings) error
}
// Server defines the interface to serve the API
@@ -636,22 +887,37 @@ type (
Start() error
}
- // UserService represents a service for managing user data
- UserService interface {
- User(ID UserID) (*User, error)
- UserByUsername(username string) (*User, error)
- Users() ([]User, error)
- UsersByRole(role UserRole) ([]User, error)
- CreateUser(user *User) error
- UpdateUser(ID UserID, user *User) error
- DeleteUser(ID UserID) error
+ // Snapshotter represents a service used to create endpoint snapshots
+ Snapshotter interface {
+ CreateSnapshot(endpoint *Endpoint) (*Snapshot, error)
}
- RoleService interface {
- Role(ID RoleID) (*Role, error)
- Roles() ([]Role, error)
- CreateRole(role *Role) error
- UpdateRole(ID RoleID, role *Role) error
+ // StackService represents a service for managing stack data
+ StackService interface {
+ Stack(ID StackID) (*Stack, error)
+ StackByName(name string) (*Stack, error)
+ Stacks() ([]Stack, error)
+ CreateStack(stack *Stack) error
+ UpdateStack(ID StackID, stack *Stack) error
+ DeleteStack(ID StackID) error
+ GetNextIdentifier() int
+ }
+
+ // SwarmStackManager represents a service to manage Swarm stacks
+ SwarmStackManager interface {
+ Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
+ Logout(endpoint *Endpoint) error
+ Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
+ Remove(stack *Stack, endpoint *Endpoint) error
+ }
+
+ // TagService represents a service for managing tag data
+ TagService interface {
+ Tags() ([]Tag, error)
+ Tag(ID TagID) (*Tag, error)
+ CreateTag(tag *Tag) error
+ UpdateTag(ID TagID, tag *Tag) error
+ DeleteTag(ID TagID) error
}
// TeamService represents a service for managing user data
@@ -677,62 +943,13 @@ type (
DeleteTeamMembershipByTeamID(teamID TeamID) error
}
- // EndpointService represents a service for managing endpoint data
- EndpointService interface {
- Endpoint(ID EndpointID) (*Endpoint, error)
- Endpoints() ([]Endpoint, error)
- CreateEndpoint(endpoint *Endpoint) error
- UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
- DeleteEndpoint(ID EndpointID) error
- Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
- GetNextIdentifier() int
- }
-
- // EndpointGroupService represents a service for managing endpoint group data
- EndpointGroupService interface {
- EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
- EndpointGroups() ([]EndpointGroup, error)
- CreateEndpointGroup(group *EndpointGroup) error
- UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
- DeleteEndpointGroup(ID EndpointGroupID) error
- }
-
- // RegistryService represents a service for managing registry data
- RegistryService interface {
- Registry(ID RegistryID) (*Registry, error)
- Registries() ([]Registry, error)
- CreateRegistry(registry *Registry) error
- UpdateRegistry(ID RegistryID, registry *Registry) error
- DeleteRegistry(ID RegistryID) error
- }
-
- // StackService represents a service for managing stack data
- StackService interface {
- Stack(ID StackID) (*Stack, error)
- StackByName(name string) (*Stack, error)
- Stacks() ([]Stack, error)
- CreateStack(stack *Stack) error
- UpdateStack(ID StackID, stack *Stack) error
- DeleteStack(ID StackID) error
- GetNextIdentifier() int
- }
-
- // DockerHubService represents a service for managing the DockerHub object
- DockerHubService interface {
- DockerHub() (*DockerHub, error)
- UpdateDockerHub(registry *DockerHub) error
- }
-
- // SettingsService represents a service for managing application settings
- SettingsService interface {
- Settings() (*Settings, error)
- UpdateSettings(settings *Settings) error
- }
-
- // VersionService represents a service for managing version data
- VersionService interface {
- DBVersion() (int, error)
- StoreDBVersion(version int) error
+ // TemplateService represents a service for managing template data
+ TemplateService interface {
+ Templates() ([]Template, error)
+ Template(ID TemplateID) (*Template, error)
+ CreateTemplate(template *Template) error
+ UpdateTemplate(ID TemplateID, template *Template) error
+ DeleteTemplate(ID TemplateID) error
}
// TunnelServerService represents a service for managing data associated to the tunnel server
@@ -741,6 +958,23 @@ type (
UpdateInfo(info *TunnelServerInfo) error
}
+ // UserService represents a service for managing user data
+ UserService interface {
+ User(ID UserID) (*User, error)
+ UserByUsername(username string) (*User, error)
+ Users() ([]User, error)
+ UsersByRole(role UserRole) ([]User, error)
+ CreateUser(user *User) error
+ UpdateUser(ID UserID, user *User) error
+ DeleteUser(ID UserID) error
+ }
+
+ // VersionService represents a service for managing version data
+ VersionService interface {
+ DBVersion() (int, error)
+ StoreDBVersion(version int) error
+ }
+
// WebhookService represents a service for managing webhook data.
WebhookService interface {
Webhooks() ([]Webhook, error)
@@ -751,175 +985,31 @@ type (
DeleteWebhook(serviceID WebhookID) error
}
- // ResourceControlService represents a service for managing resource control data
- ResourceControlService interface {
- ResourceControl(ID ResourceControlID) (*ResourceControl, error)
- ResourceControlByResourceIDAndType(resourceID string, resourceType ResourceControlType) (*ResourceControl, error)
- ResourceControls() ([]ResourceControl, error)
- CreateResourceControl(rc *ResourceControl) error
- UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error
- DeleteResourceControl(ID ResourceControlID) 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
}
- // ScheduleService represents a service for managing schedule data
- ScheduleService interface {
- Schedule(ID ScheduleID) (*Schedule, error)
- Schedules() ([]Schedule, error)
- SchedulesByJobType(jobType JobType) ([]Schedule, error)
- CreateSchedule(schedule *Schedule) error
- UpdateSchedule(ID ScheduleID, schedule *Schedule) error
- DeleteSchedule(ID ScheduleID) 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
}
-
- // TagService represents a service for managing tag data
- TagService interface {
- Tags() ([]Tag, error)
- CreateTag(tag *Tag) error
- DeleteTag(ID TagID) error
- }
-
- // TemplateService represents a service for managing template data
- TemplateService interface {
- Templates() ([]Template, error)
- Template(ID TemplateID) (*Template, error)
- CreateTemplate(template *Template) error
- UpdateTemplate(ID TemplateID, template *Template) error
- DeleteTemplate(ID TemplateID) error
- }
-
- // ExtensionService represents a service for managing extension data
- ExtensionService interface {
- Extension(ID ExtensionID) (*Extension, error)
- Extensions() ([]Extension, error)
- Persist(extension *Extension) error
- DeleteExtension(ID ExtensionID) error
- }
-
- // CryptoService represents a service for encrypting/hashing data
- CryptoService interface {
- Hash(data string) (string, error)
- CompareHashAndData(hash string, data string) error
- }
-
- // DigitalSignatureService represents a service to manage digital signatures
- DigitalSignatureService interface {
- ParseKeyPair(private, public []byte) error
- GenerateKeyPair() ([]byte, []byte, error)
- EncodedPublicKey() string
- PEMHeaders() (string, string)
- CreateSignature(message string) (string, error)
- }
-
- // JWTService represents a service for managing JWT tokens
- JWTService interface {
- GenerateToken(data *TokenData) (string, error)
- ParseAndVerifyToken(token string) (*TokenData, error)
- }
-
- // FileService represents a service for managing files
- FileService interface {
- GetFileContent(filePath string) ([]byte, error)
- Rename(oldPath, newPath string) error
- RemoveDirectory(directoryPath string) error
- StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
- GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
- DeleteTLSFile(folder string, fileType TLSFileType) error
- DeleteTLSFiles(folder string) error
- GetStackProjectPath(stackIdentifier string) string
- StoreStackFileFromBytes(stackIdentifier, 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
- LoadKeyPair() ([]byte, []byte, error)
- WriteJSONToFile(path string, content interface{}) error
- FileExists(path string) (bool, error)
- StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
- GetScheduleFolder(identifier string) string
- ExtractExtensionArchive(data []byte) error
- GetBinaryFolder() string
- }
-
- // GitService represents a service for managing Git
- GitService interface {
- ClonePublicRepository(repositoryURL, referenceName string, destination string) error
- ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
- }
-
- // JobScheduler represents a service to run jobs on a periodic basis
- JobScheduler interface {
- ScheduleJob(runner JobRunner) error
- UpdateJobSchedule(runner JobRunner) error
- UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error
- UnscheduleJob(ID ScheduleID)
- Start()
- }
-
- // JobRunner represents a service that can be used to run a job
- JobRunner interface {
- Run()
- GetSchedule() *Schedule
- }
-
- // Snapshotter represents a service used to create endpoint snapshots
- Snapshotter interface {
- CreateSnapshot(endpoint *Endpoint) (*Snapshot, error)
- }
-
- // LDAPService represents a service used to authenticate users against a LDAP/AD
- LDAPService interface {
- AuthenticateUser(username, password string, settings *LDAPSettings) error
- TestConnectivity(settings *LDAPSettings) error
- GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
- }
-
- // SwarmStackManager represents a service to manage Swarm stacks
- SwarmStackManager interface {
- Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
- Logout(endpoint *Endpoint) error
- Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
- Remove(stack *Stack, endpoint *Endpoint) error
- }
-
- // ComposeStackManager represents a service to manage Compose stacks
- ComposeStackManager interface {
- Up(stack *Stack, endpoint *Endpoint) error
- Down(stack *Stack, endpoint *Endpoint) error
- }
-
- // JobService represents a service to manage job execution on hosts
- JobService interface {
- ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
- }
-
- // ExtensionManager represents a service used to manage extensions
- ExtensionManager interface {
- FetchExtensionDefinitions() ([]Extension, error)
- InstallExtension(extension *Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error
- EnableExtension(extension *Extension, licenseKey string) error
- DisableExtension(extension *Extension) error
- UpdateExtension(extension *Extension, version string) error
- StartExtensions() error
- }
-
- // ReverseTunnelService represensts a service used to manage reverse tunnel connections.
- ReverseTunnelService interface {
- StartTunnelServer(addr, port string, snapshotter Snapshotter) error
- GenerateEdgeKey(url, host string, endpointIdentifier int) string
- SetTunnelStatusToActive(endpointID EndpointID)
- SetTunnelStatusToRequired(endpointID EndpointID) error
- SetTunnelStatusToIdle(endpointID EndpointID)
- GetTunnelDetails(endpointID EndpointID) *TunnelDetails
- AddSchedule(endpointID EndpointID, schedule *EdgeSchedule)
- RemoveSchedule(scheduleID ScheduleID)
- }
)
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "1.23.2"
+ APIVersion = "1.24.0"
// DBVersion is the version number of the Portainer database
- DBVersion = 22
+ DBVersion = 23
// AssetsServerURL represents the URL of the Portainer asset server
AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com"
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
@@ -944,20 +1034,81 @@ const (
// to be used when communicating with an agent
PortainerAgentSignatureMessage = "Portainer-App"
// ExtensionServer represents the server used by Portainer to communicate with extensions
- ExtensionServer = "localhost"
+ ExtensionServer = "127.0.0.1"
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
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 (
- // TLSFileCA represents a TLS CA certificate file
- TLSFileCA TLSFileType = iota
- // TLSFileCert represents a TLS certificate file
- TLSFileCert
- // TLSFileKey represents a TLS key file
- TLSFileKey
+ _ AuthenticationMethod = iota
+ // AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
+ AuthenticationInternal
+ // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
+ AuthenticationLDAP
+ //AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
+ 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
+ StoridgeEndpointExtension
+)
+
+const (
+ _ EndpointStatus = iota
+ // EndpointStatusUp is used to represent an available endpoint
+ EndpointStatusUp
+ // EndpointStatusDown is used to represent an unavailable endpoint
+ EndpointStatusDown
+)
+
+const (
+ _ EndpointType = iota
+ // DockerEnvironment represents an endpoint connected to a Docker environment
+ DockerEnvironment
+ // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
+ AgentOnDockerEnvironment
+ // AzureEnvironment represents an endpoint connected to an Azure environment
+ AzureEnvironment
+ // EdgeAgentEnvironment represents an endpoint connected to an Edge agent
+ EdgeAgentEnvironment
+)
+
+const (
+ _ ExtensionID = iota
+ // RegistryManagementExtension represents the registry management extension
+ RegistryManagementExtension
+ // OAuthAuthenticationExtension represents the OAuth authentication extension
+ OAuthAuthenticationExtension
+ // RBACExtension represents the RBAC extension
+ RBACExtension
+)
+
+const (
+ _ JobType = iota
+ // ScriptExecutionJobType is a non-system job used to execute a script against a list of
+ // endpoints via privileged containers
+ ScriptExecutionJobType
+ // SnapshotJobType is a system job used to create endpoint snapshots
+ SnapshotJobType
+ // EndpointSyncJobType is a system job used to synchronize endpoints from
+ // an external definition store
+ EndpointSyncJobType
)
const (
@@ -969,21 +1120,15 @@ const (
)
const (
- _ UserRole = iota
- // AdministratorRole represents an administrator user role
- AdministratorRole
- // StandardUserRole represents a regular user role
- StandardUserRole
-)
-
-const (
- _ AuthenticationMethod = iota
- // AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
- AuthenticationInternal
- // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
- AuthenticationLDAP
- //AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
- AuthenticationOAuth
+ _ RegistryType = iota
+ // QuayRegistry represents a Quay.io registry
+ QuayRegistry
+ // AzureRegistry represents an ACR registry
+ AzureRegistry
+ // CustomRegistry represents a custom registry
+ CustomRegistry
+ // GitlabRegistry represents a gitlab registry
+ GitlabRegistry
)
const (
@@ -1010,24 +1155,6 @@ const (
ConfigResourceControl
)
-const (
- _ EndpointExtensionType = iota
- // StoridgeEndpointExtension represents the Storidge extension
- StoridgeEndpointExtension
-)
-
-const (
- _ EndpointType = iota
- // DockerEnvironment represents an endpoint connected to a Docker environment
- DockerEnvironment
- // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
- AgentOnDockerEnvironment
- // AzureEnvironment represents an endpoint connected to an Azure environment
- AzureEnvironment
- // EdgeAgentEnvironment represents an endpoint connected to an Edge agent
- EdgeAgentEnvironment
-)
-
const (
_ StackType = iota
// DockerSwarmStack represents a stack managed via docker stack
@@ -1044,14 +1171,25 @@ 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 (
- _ EndpointStatus = iota
- // EndpointStatusUp is used to represent an available endpoint
- EndpointStatusUp
- // EndpointStatusDown is used to represent an unavailable endpoint
- EndpointStatusDown
+ // TLSFileCA represents a TLS CA certificate file
+ TLSFileCA TLSFileType = iota
+ // TLSFileCert represents a TLS certificate file
+ TLSFileCert
+ // TLSFileKey represents a TLS key file
+ TLSFileKey
+)
+
+const (
+ _ UserRole = iota
+ // AdministratorRole represents an administrator user role
+ AdministratorRole
+ // StandardUserRole represents a regular user role
+ StandardUserRole
)
const (
@@ -1060,40 +1198,6 @@ const (
ServiceWebhook
)
-const (
- _ ExtensionID = iota
- // RegistryManagementExtension represents the registry management extension
- RegistryManagementExtension
- // OAuthAuthenticationExtension represents the OAuth authentication extension
- OAuthAuthenticationExtension
- // RBACExtension represents the RBAC extension
- RBACExtension
-)
-
-const (
- _ JobType = iota
- // ScriptExecutionJobType is a non-system job used to execute a script against a list of
- // endpoints via privileged containers
- ScriptExecutionJobType
- // SnapshotJobType is a system job used to create endpoint snapshots
- SnapshotJobType
- // EndpointSyncJobType is a system job used to synchronize endpoints from
- // an external definition store
- EndpointSyncJobType
-)
-
-const (
- _ RegistryType = iota
- // QuayRegistry represents a Quay.io registry
- QuayRegistry
- // AzureRegistry represents an ACR registry
- AzureRegistry
- // CustomRegistry represents a custom registry
- CustomRegistry
- // GitlabRegistry represents a gitlab registry
- GitlabRegistry
-)
-
const (
// EdgeAgentIdle represents an idle state for a tunnel connected to an Edge endpoint.
EdgeAgentIdle string = "IDLE"
diff --git a/api/swagger.yaml b/api/swagger.yaml
index dd980a700..6e39642e9 100644
--- a/api/swagger.yaml
+++ b/api/swagger.yaml
@@ -54,7 +54,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
- version: "1.23.2"
+ version: "1.24.0"
title: "Portainer API"
contact:
email: "info@portainer.io"
@@ -3174,7 +3174,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
- example: "1.23.2"
+ example: "1.24.0"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"
diff --git a/api/swagger_config.json b/api/swagger_config.json
index dc70a5211..905e134a3 100644
--- a/api/swagger_config.json
+++ b/api/swagger_config.json
@@ -1,5 +1,5 @@
{
"packageName": "portainer",
- "packageVersion": "1.23.2",
+ "packageVersion": "1.24.0",
"projectName": "portainer"
}
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 881453c9a..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,15 +30,16 @@ angular.module('portainer', [
'portainer.agent',
'portainer.azure',
'portainer.docker',
+ 'portainer.edge',
'portainer.extensions',
'portainer.integrations',
'rzModule',
- 'moment-picker'
+ 'moment-picker',
]);
if (require) {
var req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
- req.keys().forEach(function(key) {
+ req.keys().forEach(function (key) {
req(key);
});
}
diff --git a/app/agent/components/file-uploader/file-uploader-controller.js b/app/agent/components/file-uploader/file-uploader-controller.js
index e6516c67c..d0c5ad798 100644
--- a/app/agent/components/file-uploader/file-uploader-controller.js
+++ b/app/agent/components/file-uploader/file-uploader-controller.js
@@ -4,7 +4,7 @@ angular.module('portainer.agent').controller('FileUploaderController', [
var ctrl = this;
ctrl.state = {
- uploadInProgress: false
+ uploadInProgress: false,
};
ctrl.onFileSelected = onFileSelected;
@@ -19,5 +19,5 @@ angular.module('portainer.agent').controller('FileUploaderController', [
ctrl.state.uploadInProgress = false;
});
}
- }
+ },
]);
diff --git a/app/agent/components/file-uploader/file-uploader.html b/app/agent/components/file-uploader/file-uploader.html
index e092ce5d6..5354b5c2f 100644
--- a/app/agent/components/file-uploader/file-uploader.html
+++ b/app/agent/components/file-uploader/file-uploader.html
@@ -1,6 +1,3 @@
-