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 @@ - diff --git a/app/agent/components/file-uploader/file-uploader.js b/app/agent/components/file-uploader/file-uploader.js index 6427bae4a..6232c7f8d 100644 --- a/app/agent/components/file-uploader/file-uploader.js +++ b/app/agent/components/file-uploader/file-uploader.js @@ -2,6 +2,6 @@ angular.module('portainer.agent').component('fileUploader', { templateUrl: './file-uploader.html', controller: 'FileUploaderController', bindings: { - uploadFile: ' - - - + +
@@ -43,36 +49,33 @@ - + - - + + + + + + + diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js index 7ac1b5580..570f93e16 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js @@ -1,6 +1,6 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { templateUrl: './containerNetworksDatatable.html', - controller: 'GenericDatatableController', + controller: 'ContainerNetworksDatatableController', bindings: { titleText: '@', titleIcon: '@', @@ -12,6 +12,6 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { joinNetworkActionInProgress: '<', leaveNetworkActionInProgress: '<', leaveNetworkAction: '<', - nodeName: '<' - } + nodeName: '<', + }, }); diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js new file mode 100644 index 000000000..82839c40f --- /dev/null +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js @@ -0,0 +1,81 @@ +import _ from 'lodash-es'; + +angular.module('portainer.docker') + .controller('ContainerNetworksDatatableController', ['$scope', '$controller', 'DatatableService', + function ($scope, $controller, DatatableService) { + + angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); + this.state = Object.assign(this.state, { + expandedItems: [], + expandAll: true + }); + + this.expandItem = function (item, expanded) { + if (!this.itemCanExpand(item)) { + return; + } + + item.Expanded = expanded; + if (!expanded) { + item.Highlighted = false; + } + if (!item.Expanded) { + this.state.expandAll = false; + } + }; + + this.itemCanExpand = function (item) { + return item.GlobalIPv6Address !== ''; + } + + this.hasExpandableItems = function () { + return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length; + }; + + this.expandAll = function () { + this.state.expandAll = !this.state.expandAll; + _.forEach(this.dataset, (item) => { + if (this.itemCanExpand(item)) { + this.expandItem(item, this.state.expandAll); + } + }); + }; + + this.$onInit = function () { + this.setDefaults(); + this.prepareTableFromDataset(); + + this.state.orderBy = this.orderBy; + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } + + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); + if (storedFilters !== null) { + this.filters = storedFilters; + } + if (this.filters && this.filters.state) { + this.filters.state.open = false; + } + + var storedSettings = DatatableService.getDataTableSettings(this.tableKey); + if (storedSettings !== null) { + this.settings = storedSettings; + this.settings.open = false; + } + + _.forEach(this.dataset, (item) => { + item.Expanded = true; + item.Highlighted = true; + }); + }; + } + ]); \ No newline at end of file diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html index 37f3152b5..326deef73 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html @@ -2,13 +2,11 @@
-
- {{ $ctrl.titleText }} -
+
{{ $ctrl.titleText }}
- Go - to parent + Go to parent
- + - + - {{ item.Name }} - - - {{ - item.Name }} + {{ item.Name }} + {{ item.Name }} {{ item.Size | humansize }} {{ item.ModTime | getisodatefromtimestamp }} - + Download @@ -94,4 +97,4 @@ - \ No newline at end of file + diff --git a/app/agent/components/files-datatable/files-datatable.js b/app/agent/components/files-datatable/files-datatable.js index d1735cf48..d75d0f970 100644 --- a/app/agent/components/files-datatable/files-datatable.js +++ b/app/agent/components/files-datatable/files-datatable.js @@ -15,8 +15,8 @@ angular.module('portainer.agent').component('filesDatatable', { rename: '&', download: '&', delete: '&', - + isUploadAllowed: '<', - onFileSelectedForUpload: '<' - } + onFileSelectedForUpload: '<', + }, }); diff --git a/app/agent/components/host-browser/host-browser-controller.js b/app/agent/components/host-browser/host-browser-controller.js index 797f5c6ac..c9c1e5e0f 100644 --- a/app/agent/components/host-browser/host-browser-controller.js +++ b/app/agent/components/host-browser/host-browser-controller.js @@ -1,12 +1,15 @@ import _ from 'lodash-es'; angular.module('portainer.agent').controller('HostBrowserController', [ - 'HostBrowserService', 'Notifications', 'FileSaver', 'ModalService', + 'HostBrowserService', + 'Notifications', + 'FileSaver', + 'ModalService', function HostBrowserController(HostBrowserService, Notifications, FileSaver, ModalService) { var ctrl = this; var ROOT_PATH = '/host'; ctrl.state = { - path: ROOT_PATH + path: ROOT_PATH, }; ctrl.goToParent = goToParent; @@ -21,7 +24,7 @@ angular.module('portainer.agent').controller('HostBrowserController', [ function getRelativePath(path) { path = path || ctrl.state.path; - var rootPathRegex = new RegExp('^' + ROOT_PATH + '\/?'); + var rootPathRegex = new RegExp('^' + ROOT_PATH + '/?'); var relativePath = path.replace(rootPathRegex, '/'); return relativePath; } @@ -71,7 +74,7 @@ angular.module('portainer.agent').controller('HostBrowserController', [ HostBrowserService.get(filePath) .then(function onFileReceived(data) { var downloadData = new Blob([data.file], { - type: 'text/plain;charset=utf-8' + type: 'text/plain;charset=utf-8', }); FileSaver.saveAs(downloadData, file); }) @@ -83,15 +86,12 @@ angular.module('portainer.agent').controller('HostBrowserController', [ function confirmDeleteFile(name) { var filePath = buildPath(ctrl.state.path, name); - ModalService.confirmDeletion( - 'Are you sure that you want to delete ' + getRelativePath(filePath) + ' ?', - function onConfirm(confirmed) { - if (!confirmed) { - return; - } - return deleteFile(filePath); + ModalService.confirmDeletion('Are you sure that you want to delete ' + getRelativePath(filePath) + ' ?', function onConfirm(confirmed) { + if (!confirmed) { + return; } - ); + return deleteFile(filePath); + }); } function deleteFile(path) { @@ -145,5 +145,5 @@ angular.module('portainer.agent').controller('HostBrowserController', [ function refreshList() { getFilesForPath(ctrl.state.path); } - } + }, ]); diff --git a/app/agent/components/host-browser/host-browser.html b/app/agent/components/host-browser/host-browser.html index cf7b59306..b81ecc744 100644 --- a/app/agent/components/host-browser/host-browser.html +++ b/app/agent/components/host-browser/host-browser.html @@ -1,6 +1,8 @@ - diff --git a/app/agent/components/host-browser/host-browser.js b/app/agent/components/host-browser/host-browser.js index d702255eb..9b16b5a01 100644 --- a/app/agent/components/host-browser/host-browser.js +++ b/app/agent/components/host-browser/host-browser.js @@ -1,5 +1,5 @@ angular.module('portainer.agent').component('hostBrowser', { controller: 'HostBrowserController', templateUrl: './host-browser.html', - bindings: {} + bindings: {}, }); diff --git a/app/agent/components/node-selector/node-selector.js b/app/agent/components/node-selector/node-selector.js index 0d9c93ed3..2f7e14e82 100644 --- a/app/agent/components/node-selector/node-selector.js +++ b/app/agent/components/node-selector/node-selector.js @@ -2,6 +2,6 @@ angular.module('portainer.agent').component('nodeSelector', { templateUrl: './nodeSelector.html', controller: 'NodeSelectorController', bindings: { - model: '=' - } + model: '=', + }, }); diff --git a/app/agent/components/node-selector/nodeSelector.html b/app/agent/components/node-selector/nodeSelector.html index 1a910396c..4ab9e75bb 100644 --- a/app/agent/components/node-selector/nodeSelector.html +++ b/app/agent/components/node-selector/nodeSelector.html @@ -1,8 +1,6 @@
- +
diff --git a/app/agent/components/node-selector/nodeSelectorController.js b/app/agent/components/node-selector/nodeSelectorController.js index be2f6ce92..ce44f8568 100644 --- a/app/agent/components/node-selector/nodeSelectorController.js +++ b/app/agent/components/node-selector/nodeSelectorController.js @@ -1,18 +1,20 @@ -angular.module('portainer.agent') -.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) { - var ctrl = this; +angular.module('portainer.agent').controller('NodeSelectorController', [ + 'AgentService', + 'Notifications', + function (AgentService, Notifications) { + var ctrl = this; - this.$onInit = function() { - AgentService.agents() - .then(function success(data) { - ctrl.agents = data; - if (!ctrl.model) { - ctrl.model = data[0].NodeName; - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to load agents'); - }); - }; - -}]); + this.$onInit = function () { + AgentService.agents() + .then(function success(data) { + ctrl.agents = data; + if (!ctrl.model) { + ctrl.model = data[0].NodeName; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load agents'); + }); + }; + }, +]); diff --git a/app/agent/components/volume-browser/volume-browser.js b/app/agent/components/volume-browser/volume-browser.js index bea11e61a..5c2b2b78d 100644 --- a/app/agent/components/volume-browser/volume-browser.js +++ b/app/agent/components/volume-browser/volume-browser.js @@ -4,6 +4,6 @@ angular.module('portainer.agent').component('volumeBrowser', { bindings: { volumeId: '<', nodeName: '<', - isUploadEnabled: '<' - } + isUploadEnabled: '<', + }, }); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 97c8a4da6..5b85cfd94 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -1,6 +1,8 @@ diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js index 0f8e99d86..fccde7753 100644 --- a/app/agent/components/volume-browser/volumeBrowserController.js +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -1,137 +1,137 @@ import _ from 'lodash-es'; -angular.module('portainer.agent') -.controller('VolumeBrowserController', ['HttpRequestHelper', 'VolumeBrowserService', 'FileSaver', 'Blob', 'ModalService', 'Notifications', -function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService, Notifications) { - var ctrl = this; +angular.module('portainer.agent').controller('VolumeBrowserController', [ + 'HttpRequestHelper', + 'VolumeBrowserService', + 'FileSaver', + 'Blob', + 'ModalService', + 'Notifications', + function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService, Notifications) { + var ctrl = this; - this.state = { - path: '/' - }; + this.state = { + path: '/', + }; - this.rename = function(file, newName) { - var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; - var newFilePath = this.state.path === '/' ? newName : this.state.path + '/' + newName; + this.rename = function (file, newName) { + var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; + var newFilePath = this.state.path === '/' ? newName : this.state.path + '/' + newName; - VolumeBrowserService.rename(this.volumeId, filePath, newFilePath) - .then(function success() { - Notifications.success('File successfully renamed', newFilePath); - return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path); - }) - .then(function success(data) { - ctrl.files = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to rename file'); - }); - }; + VolumeBrowserService.rename(this.volumeId, filePath, newFilePath) + .then(function success() { + Notifications.success('File successfully renamed', newFilePath); + return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path); + }) + .then(function success(data) { + ctrl.files = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to rename file'); + }); + }; - this.delete = function(file) { - var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; + this.delete = function (file) { + var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; - ModalService.confirmDeletion( - 'Are you sure that you want to delete ' + filePath + ' ?', - function onConfirm(confirmed) { - if(!confirmed) { return; } + ModalService.confirmDeletion('Are you sure that you want to delete ' + filePath + ' ?', function onConfirm(confirmed) { + if (!confirmed) { + return; + } deleteFile(filePath); - } - ); - }; - - this.download = function(file) { - var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; - VolumeBrowserService.get(this.volumeId, filePath) - .then(function success(data) { - var downloadData = new Blob([data.file]); - FileSaver.saveAs(downloadData, file); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to download file'); - }); - }; - - this.up = function() { - var parentFolder = parentPath(this.state.path); - browse(parentFolder); - }; - - this.browse = function(folder) { - var path = buildPath(this.state.path, folder); - browse(path); - }; - - function deleteFile(file) { - VolumeBrowserService.delete(ctrl.volumeId, file) - .then(function success() { - Notifications.success('File successfully deleted', file); - return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path); - }) - .then(function success(data) { - ctrl.files = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to delete file'); - }); - } - - - function browse(path) { - VolumeBrowserService.ls(ctrl.volumeId, path) - .then(function success(data) { - ctrl.state.path = path; - ctrl.files = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to browse volume'); - }); - } - - this.onFileSelectedForUpload = function onFileSelectedForUpload(file) { - VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId) - .then(function onFileUpload() { - onFileUploaded(); - }) - .catch(function onFileUpload(err) { - Notifications.error('Failure', err, 'Unable to upload file'); }); - }; + }; - function parentPath(path) { - if (path.lastIndexOf('/') === 0) { - return '/'; + this.download = function (file) { + var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; + VolumeBrowserService.get(this.volumeId, filePath) + .then(function success(data) { + var downloadData = new Blob([data.file]); + FileSaver.saveAs(downloadData, file); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to download file'); + }); + }; + + this.up = function () { + var parentFolder = parentPath(this.state.path); + browse(parentFolder); + }; + + this.browse = function (folder) { + var path = buildPath(this.state.path, folder); + browse(path); + }; + + function deleteFile(file) { + VolumeBrowserService.delete(ctrl.volumeId, file) + .then(function success() { + Notifications.success('File successfully deleted', file); + return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path); + }) + .then(function success(data) { + ctrl.files = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete file'); + }); } - var split = _.split(path, '/'); - return _.join(_.slice(split, 0, split.length - 1), '/'); - } - - function buildPath(parent, file) { - if (parent === '/') { - return parent + file; + function browse(path) { + VolumeBrowserService.ls(ctrl.volumeId, path) + .then(function success(data) { + ctrl.state.path = path; + ctrl.files = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to browse volume'); + }); } - return parent + '/' + file; - } + this.onFileSelectedForUpload = function onFileSelectedForUpload(file) { + VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId) + .then(function onFileUpload() { + onFileUploaded(); + }) + .catch(function onFileUpload(err) { + Notifications.error('Failure', err, 'Unable to upload file'); + }); + }; - this.$onInit = function() { - HttpRequestHelper.setPortainerAgentTargetHeader(this.nodeName); - VolumeBrowserService.ls(this.volumeId, this.state.path) - .then(function success(data) { - ctrl.files = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to browse volume'); - }); - }; + function parentPath(path) { + if (path.lastIndexOf('/') === 0) { + return '/'; + } - function onFileUploaded() { - refreshList(); - } + var split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } - function refreshList() { - browse(ctrl.state.path); - } + function buildPath(parent, file) { + if (parent === '/') { + return parent + file; + } + return parent + '/' + file; + } - + this.$onInit = function () { + HttpRequestHelper.setPortainerAgentTargetHeader(this.nodeName); + VolumeBrowserService.ls(this.volumeId, this.state.path) + .then(function success(data) { + ctrl.files = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to browse volume'); + }); + }; -}]); + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + browse(ctrl.state.path); + } + }, +]); diff --git a/app/agent/models/agent.js b/app/agent/models/agent.js index 3e171ff07..54866ef90 100644 --- a/app/agent/models/agent.js +++ b/app/agent/models/agent.js @@ -1,5 +1,5 @@ export function AgentViewModel(data) { this.IPAddress = data.IPAddress; - this.NodeName = data.NodeName; - this.NodeRole = data.NodeRole; + this.NodeName = data.NodeName; + this.NodeRole = data.NodeRole; } diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index 522296c3a..d7d0c6a2f 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -1,12 +1,19 @@ -angular.module('portainer.agent') -.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', +angular.module('portainer.agent').factory('Agent', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + 'StateManager', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { - 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', { - endpointId: EndpointProvider.endpointID, - version: StateManager.getAgentApiVersion + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', + { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion, + }, + { + query: { method: 'GET', isArray: true }, + } + ); }, - { - query: { method: 'GET', isArray: true } - }); -}]); +]); diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index 82e5d4b21..852ee0535 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -1,27 +1,39 @@ import { browseGetResponse } from './response/browse'; -angular.module('portainer.agent') -.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', +angular.module('portainer.agent').factory('Browse', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + 'StateManager', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { - 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', { - endpointId: EndpointProvider.endpointID, - version: StateManager.getAgentApiVersion + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', + { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion, + }, + { + ls: { + method: 'GET', + isArray: true, + params: { action: 'ls' }, + }, + get: { + method: 'GET', + params: { action: 'get' }, + transformResponse: browseGetResponse, + responseType: 'arraybuffer', + }, + delete: { + method: 'DELETE', + params: { action: 'delete' }, + }, + rename: { + method: 'PUT', + params: { action: 'rename' }, + }, + } + ); }, - { - ls: { - method: 'GET', isArray: true, params: { action: 'ls' } - }, - get: { - method: 'GET', params: { action: 'get' }, - transformResponse: browseGetResponse, - responseType: 'arraybuffer' - }, - delete: { - method: 'DELETE', params: { action: 'delete' } - }, - rename: { - method: 'PUT', params: { action: 'rename' } - } - }); -}]); +]); diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js index f184d2544..a5fb198ae 100644 --- a/app/agent/rest/host.js +++ b/app/agent/rest/host.js @@ -1,16 +1,19 @@ angular.module('portainer.agent').factory('Host', [ - '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + 'StateManager', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { 'use strict'; return $resource( API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action', { endpointId: EndpointProvider.endpointID, - version: StateManager.getAgentApiVersion + version: StateManager.getAgentApiVersion, }, { - info: { method: 'GET', params: { action: 'info' } } + info: { method: 'GET', params: { action: 'info' } }, } ); - } + }, ]); diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js index 7eeb93f2e..b6527dfb4 100644 --- a/app/agent/rest/ping.js +++ b/app/agent/rest/ping.js @@ -1,11 +1,14 @@ angular.module('portainer.agent').factory('AgentPing', [ - '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + '$q', function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { 'use strict'; return $resource( API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/ping', - { - endpointId: EndpointProvider.endpointID + { + endpointId: EndpointProvider.endpointID, }, { ping: { @@ -13,8 +16,7 @@ angular.module('portainer.agent').factory('AgentPing', [ interceptor: { response: function versionInterceptor(response) { var instance = response.resource; - var version = - response.headers('Portainer-Agent-Api-Version') || 1; + var version = response.headers('Portainer-Agent-Api-Version') || 1; instance.version = Number(version); return instance; }, @@ -24,10 +26,10 @@ angular.module('portainer.agent').factory('AgentPing', [ return { version: 1 }; } return $q.reject(error); - } - } - } + }, + }, + }, } ); - } + }, ]); diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js index a78755b35..3d9e8d606 100644 --- a/app/agent/rest/v1/agent.js +++ b/app/agent/rest/v1/agent.js @@ -1,10 +1,17 @@ -angular.module('portainer.agent') -.factory('AgentVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { - endpointId: EndpointProvider.endpointID +angular.module('portainer.agent').factory('AgentVersion1', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', + { + endpointId: EndpointProvider.endpointID, + }, + { + query: { method: 'GET', isArray: true }, + } + ); }, - { - query: { method: 'GET', isArray: true } - }); -}]); +]); diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js index 62a198743..89c18b384 100644 --- a/app/agent/rest/v1/browse.js +++ b/app/agent/rest/v1/browse.js @@ -1,25 +1,37 @@ import { browseGetResponse } from '../response/browse'; -angular.module('portainer.agent') -.factory('BrowseVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', { - endpointId: EndpointProvider.endpointID +angular.module('portainer.agent').factory('BrowseVersion1', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', + { + endpointId: EndpointProvider.endpointID, + }, + { + ls: { + method: 'GET', + isArray: true, + params: { action: 'ls' }, + }, + get: { + method: 'GET', + params: { action: 'get' }, + transformResponse: browseGetResponse, + responseType: 'arraybuffer', + }, + delete: { + method: 'DELETE', + params: { action: 'delete' }, + }, + rename: { + method: 'PUT', + params: { action: 'rename' }, + }, + } + ); }, - { - ls: { - method: 'GET', isArray: true, params: { action: 'ls' } - }, - get: { - method: 'GET', params: { action: 'get' }, - transformResponse: browseGetResponse, - responseType: 'arraybuffer' - }, - delete: { - method: 'DELETE', params: { action: 'delete' } - }, - rename: { - method: 'PUT', params: { action: 'rename' } - } - }); -}]); +]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index af471da09..b9d938d94 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -1,7 +1,12 @@ import { AgentViewModel } from '../models/agent'; angular.module('portainer.agent').factory('AgentService', [ - '$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager', + '$q', + 'Agent', + 'AgentVersion1', + 'HttpRequestHelper', + 'Host', + 'StateManager', function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { 'use strict'; var service = {}; @@ -24,10 +29,11 @@ angular.module('portainer.agent').factory('AgentService', [ var agentVersion = getAgentApiVersion(); var service = agentVersion > 1 ? Agent : AgentVersion1; - - service.query({ version: agentVersion }) + + service + .query({ version: agentVersion }) .$promise.then(function success(data) { - var agents = data.map(function(item) { + var agents = data.map(function (item) { return new AgentViewModel(item); }); deferred.resolve(agents); @@ -40,5 +46,5 @@ angular.module('portainer.agent').factory('AgentService', [ } return service; - } + }, ]); diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js index 6f292c36a..8de01f815 100644 --- a/app/agent/services/hostBrowserService.js +++ b/app/agent/services/hostBrowserService.js @@ -1,5 +1,10 @@ angular.module('portainer.agent').factory('HostBrowserService', [ - 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager', + 'Browse', + 'Upload', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + '$q', + 'StateManager', function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) { var service = {}; @@ -24,7 +29,7 @@ angular.module('portainer.agent').factory('HostBrowserService', [ function rename(path, newPath) { var payload = { CurrentFilePath: path, - NewFilePath: newPath + NewFilePath: newPath, }; return Browse.rename({}, payload).$promise; } @@ -32,21 +37,15 @@ angular.module('portainer.agent').factory('HostBrowserService', [ function upload(path, file, onProgress) { var deferred = $q.defer(); var agentVersion = StateManager.getAgentApiVersion(); - var url = - API_ENDPOINT_ENDPOINTS + - '/' + - EndpointProvider.endpointID() + - '/docker' + - (agentVersion > 1 ? '/v' + agentVersion : '') + - '/browse/put'; + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker' + (agentVersion > 1 ? '/v' + agentVersion : '') + '/browse/put'; Upload.upload({ url: url, - data: { file: file, Path: path } + data: { file: file, Path: path }, }).then(deferred.resolve, deferred.reject, onProgress); return deferred.promise; } return service; - } + }, ]); diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js index 765d47a5f..cc3133f22 100644 --- a/app/agent/services/pingService.js +++ b/app/agent/services/pingService.js @@ -10,5 +10,5 @@ angular.module('portainer.agent').service('AgentPingService', [ } return service; - } + }, ]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index a4ffdbf9d..002edb08b 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,5 +1,11 @@ angular.module('portainer.agent').factory('VolumeBrowserService', [ - 'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload', + 'StateManager', + 'Browse', + 'BrowseVersion1', + '$q', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + 'Upload', function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { 'use strict'; var service = {}; @@ -14,22 +20,22 @@ angular.module('portainer.agent').factory('VolumeBrowserService', [ return agentVersion > 1 ? Browse : BrowseVersion1; } - service.ls = function(volumeId, path) { + service.ls = function (volumeId, path) { return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; - service.get = function(volumeId, path) { + service.get = function (volumeId, path) { return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; - service.delete = function(volumeId, path) { + service.delete = function (volumeId, path) { return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; - service.rename = function(volumeId, path, newPath) { + service.rename = function (volumeId, path, newPath) { var payload = { - CurrentFilePath: path, - NewFilePath: newPath + CurrentFilePath: path, + NewFilePath: newPath, }; return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; }; @@ -37,26 +43,19 @@ angular.module('portainer.agent').factory('VolumeBrowserService', [ service.upload = function upload(path, file, volumeId, onProgress) { var deferred = $q.defer(); var agentVersion = StateManager.getAgentApiVersion(); - if (agentVersion <2) { + if (agentVersion < 2) { deferred.reject('upload is not supported on this agent version'); return; } - var url = - API_ENDPOINT_ENDPOINTS + - '/' + - EndpointProvider.endpointID() + - '/docker' + - '/v' + agentVersion + - '/browse/put?volumeID=' + - volumeId; + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker' + '/v' + agentVersion + '/browse/put?volumeID=' + volumeId; Upload.upload({ url: url, - data: { file: file, Path: path } + data: { file: file, Path: path }, }).then(deferred.resolve, deferred.reject, onProgress); return deferred.promise; }; return service; - } + }, ]); diff --git a/app/app.js b/app/app.js index 95cf796bf..6cec23d30 100644 --- a/app/app.js +++ b/app/app.js @@ -1,46 +1,55 @@ import $ from 'jquery'; -import '@babel/polyfill' +import '@babel/polyfill'; -angular.module('portainer') -.run(['$rootScope', '$state', '$interval', 'LocalStorage', 'EndpointProvider', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', -function ($rootScope, $state, $interval, LocalStorage, EndpointProvider, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { - 'use strict'; +angular.module('portainer').run([ + '$rootScope', + '$state', + '$interval', + 'LocalStorage', + 'EndpointProvider', + 'SystemService', + 'cfpLoadingBar', + '$transitions', + 'HttpRequestHelper', + function ($rootScope, $state, $interval, LocalStorage, EndpointProvider, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { + 'use strict'; - EndpointProvider.initialize(); + EndpointProvider.initialize(); - $rootScope.$state = $state; + $rootScope.$state = $state; - // Workaround to prevent the loading bar from going backward - // https://github.com/chieffancypants/angular-loading-bar/issues/273 - var originalSet = cfpLoadingBar.set; - cfpLoadingBar.set = function overrideSet(n) { - if (n > cfpLoadingBar.status()) { - originalSet.apply(cfpLoadingBar, arguments); - } - }; + // Workaround to prevent the loading bar from going backward + // https://github.com/chieffancypants/angular-loading-bar/issues/273 + var originalSet = cfpLoadingBar.set; + cfpLoadingBar.set = function overrideSet(n) { + if (n > cfpLoadingBar.status()) { + originalSet.apply(cfpLoadingBar, arguments); + } + }; - $transitions.onBefore({}, function() { - HttpRequestHelper.resetAgentHeaders(); - }); + $transitions.onBefore({}, function () { + HttpRequestHelper.resetAgentHeaders(); + }); - $state.defaultErrorHandler(function() { - // Do not log transitionTo errors - }); + $state.defaultErrorHandler(function () { + // Do not log transitionTo errors + }); - // Keep-alive Edge endpoints by sending a ping request every minute - $interval(function() { - ping(EndpointProvider, SystemService); - }, 60 * 1000) + // Keep-alive Edge endpoints by sending a ping request every minute + $interval(function () { + ping(EndpointProvider, SystemService); + }, 60 * 1000); - $(document).ajaxSend(function (event, jqXhr, jqOpts) { - const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; - const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; - if (type && hasNoContentType) { - jqXhr.setRequestHeader('Content-Type', 'application/json'); - } - jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT()); - }); -}]); + $(document).ajaxSend(function (event, jqXhr, jqOpts) { + const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; + const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; + if (type && hasNoContentType) { + jqXhr.setRequestHeader('Content-Type', 'application/json'); + } + jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT()); + }); + }, +]); function ping(EndpointProvider, SystemService) { let endpoint = EndpointProvider.currentEndpoint(); diff --git a/app/azure/_module.js b/app/azure/_module.js index 663f2c511..a11a5aa5e 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -1,49 +1,51 @@ -angular.module('portainer.azure', ['portainer.app']) -.config(['$stateRegistryProvider', function ($stateRegistryProvider) { - 'use strict'; +angular.module('portainer.azure', ['portainer.app']).config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; - var azure = { - name: 'azure', - url: '/azure', - parent: 'root', - abstract: true - }; + var azure = { + name: 'azure', + url: '/azure', + parent: 'root', + abstract: true, + }; - var containerInstances = { - name: 'azure.containerinstances', - url: '/containerinstances', - views: { - 'content@': { - templateUrl: './views/containerinstances/containerinstances.html', - controller: 'AzureContainerInstancesController' - } - } - }; + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: './views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController', + }, + }, + }; - var containerInstanceCreation = { - name: 'azure.containerinstances.new', - url: '/new/', - views: { - 'content@': { - templateUrl: './views/containerinstances/create/createcontainerinstance.html', - controller: 'AzureCreateContainerInstanceController' - } - } - }; + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: './views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController', + }, + }, + }; - var dashboard = { - name: 'azure.dashboard', - url: '/dashboard', - views: { - 'content@': { - templateUrl: './views/dashboard/dashboard.html', - controller: 'AzureDashboardController' - } - } - }; + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: './views/dashboard/dashboard.html', + controller: 'AzureDashboardController', + }, + }, + }; - $stateRegistryProvider.register(azure); - $stateRegistryProvider.register(containerInstances); - $stateRegistryProvider.register(containerInstanceCreation); - $stateRegistryProvider.register(dashboard); -}]); + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); + }, +]); diff --git a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js index 2909d3853..ff09f0908 100644 --- a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js +++ b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js @@ -2,7 +2,7 @@ angular.module('portainer.azure').component('azureEndpointConfig', { bindings: { applicationId: '=', tenantId: '=', - authenticationKey: '=' + authenticationKey: '=', }, - templateUrl: './azureEndpointConfig.html' + templateUrl: './azureEndpointConfig.html', }); diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html index c0d839102..efc8bd79f 100644 --- a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html +++ b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html @@ -6,7 +6,7 @@
- +
@@ -14,7 +14,7 @@
- +
@@ -22,7 +22,14 @@
- +
diff --git a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js index 68401cc1e..daec3ef12 100644 --- a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js +++ b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js @@ -1,3 +1,3 @@ angular.module('portainer.azure').component('azureSidebarContent', { - templateUrl: './azureSidebarContent.html' + templateUrl: './azureSidebarContent.html', }); diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index 54361570c..f9936d78b 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -2,13 +2,10 @@
-
- {{ $ctrl.titleText }} -
+
{{ $ctrl.titleText }}
-
@@ -47,20 +52,23 @@ - + @@ -73,9 +81,7 @@
- + - {{ item.Name | truncate:50 }} + {{ item.Name | truncate: 50 }} {{ item.Location }} :{{ p.port }} - - + -
- -
-
+
+
- \ No newline at end of file + diff --git a/app/docker/components/container-quick-actions/containerQuickActions.html b/app/docker/components/container-quick-actions/containerQuickActions.html index 65ee13949..d4bbc55d1 100644 --- a/app/docker/components/container-quick-actions/containerQuickActions.html +++ b/app/docker/components/container-quick-actions/containerQuickActions.html @@ -1,10 +1,11 @@ - diff --git a/app/docker/components/container-quick-actions/containerQuickActions.js b/app/docker/components/container-quick-actions/containerQuickActions.js index 619cf7efa..6f8631df7 100644 --- a/app/docker/components/container-quick-actions/containerQuickActions.js +++ b/app/docker/components/container-quick-actions/containerQuickActions.js @@ -5,6 +5,6 @@ angular.module('portainer.docker').component('containerQuickActions', { nodeName: '<', status: '<', state: '<', - taskId: '<' - } + taskId: '<', + }, }); diff --git a/app/docker/components/container-restart-policy/container-restart-policy-controller.js b/app/docker/components/container-restart-policy/container-restart-policy-controller.js index 64a8604fc..159b8a2f5 100644 --- a/app/docker/components/container-restart-policy/container-restart-policy-controller.js +++ b/app/docker/components/container-restart-policy/container-restart-policy-controller.js @@ -1,26 +1,25 @@ -angular -.module('portainer.docker') -.controller('ContainerRestartPolicyController', [function ContainerRestartPolicyController() { - var ctrl = this; +angular.module('portainer.docker').controller('ContainerRestartPolicyController', [ + function ContainerRestartPolicyController() { + var ctrl = this; - this.state = { - editModel : {} - }; - - ctrl.save = save; - - function save() { - if (ctrl.state.editModel.name === ctrl.name && ctrl.state.editModel.maximumRetryCount === ctrl.maximumRetryCount) { - return; - } - ctrl.updateRestartPolicy(ctrl.state.editModel); - } - - this.$onInit = function() { - ctrl.state.editModel = { - name: ctrl.name ? ctrl.name : 'no', - maximumRetryCount: ctrl.maximumRetryCount + this.state = { + editModel: {}, }; - }; -} + + ctrl.save = save; + + function save() { + if (ctrl.state.editModel.name === ctrl.name && ctrl.state.editModel.maximumRetryCount === ctrl.maximumRetryCount) { + return; + } + ctrl.updateRestartPolicy(ctrl.state.editModel); + } + + this.$onInit = function () { + ctrl.state.editModel = { + name: ctrl.name ? ctrl.name : 'no', + maximumRetryCount: ctrl.maximumRetryCount, + }; + }; + }, ]); diff --git a/app/docker/components/container-restart-policy/container-restart-policy.js b/app/docker/components/container-restart-policy/container-restart-policy.js index 60e4f0c4b..bc4d02709 100644 --- a/app/docker/components/container-restart-policy/container-restart-policy.js +++ b/app/docker/components/container-restart-policy/container-restart-policy.js @@ -1,10 +1,9 @@ -angular.module('portainer.docker') -.component('containerRestartPolicy', { +angular.module('portainer.docker').component('containerRestartPolicy', { templateUrl: './container-restart-policy.html', controller: 'ContainerRestartPolicyController', bindings: { - 'name': '<', - 'maximumRetryCount': '<', - 'updateRestartPolicy': '&' - } + name: '<', + maximumRetryCount: '<', + updateRestartPolicy: '&', + }, }); diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js index 88aae5cec..6498c2edc 100644 --- a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js +++ b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js @@ -1,4 +1,4 @@ angular.module('portainer.docker').component('dashboardClusterAgentInfo', { templateUrl: './dashboardClusterAgentInfo.html', - controller: 'DashboardClusterAgentInfoController' + controller: 'DashboardClusterAgentInfoController', }); diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js index 7ba6f7f08..2b421729b 100644 --- a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js @@ -1,16 +1,17 @@ -angular.module('portainer.docker') -.controller('DashboardClusterAgentInfoController', ['AgentService', 'Notifications', -function (AgentService, Notifications) { - var ctrl = this; +angular.module('portainer.docker').controller('DashboardClusterAgentInfoController', [ + 'AgentService', + 'Notifications', + function (AgentService, Notifications) { + var ctrl = this; - this.$onInit = function() { - AgentService.agents() - .then(function success(data) { - ctrl.agentCount = data.length; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve agent information'); - }); - }; - -}]); + this.$onInit = function () { + AgentService.agents() + .then(function success(data) { + ctrl.agentCount = data.length; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve agent information'); + }); + }; + }, +]); diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index d6a1e386d..a6b8aa6a7 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -2,9 +2,7 @@
-
- {{ $ctrl.titleText }} -
+
{{ $ctrl.titleText }}
Settings @@ -16,7 +14,7 @@
NetworkIP Address + IP Address + + + + Gateway MAC Address Actions
{{ key }}
+ + + + {{ key }} + {{ value.IPAddress || '-' }} {{ value.Gateway || '-' }} {{ value.MacAddress || '-' }} -
+ {{ value.GlobalIPv6Address }} + + {{ value.IPv6Gateway || '-' }} +
Loading...
diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js index 1a0abdc3a..b89a4d63b 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js @@ -8,6 +8,6 @@ angular.module('portainer.docker').component('containerProcessesDatatable', { headerset: '<', tableKey: '@', orderBy: '@', - reverseOrder: '<' - } + reverseOrder: '<', + }, }); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html index 873657caf..9d9f8d9f8 100644 --- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html @@ -1,31 +1,69 @@ -
+
- - - - - - -
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js index 2f82916a1..b6f83f273 100644 --- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js @@ -7,6 +7,6 @@ angular.module('portainer.docker').component('containersDatatableActions', { noStoppedItemsSelected: '=', noRunningItemsSelected: '=', noPausedItemsSelected: '=', - showAddAction: '<' - } + showAddAction: '<', + }, }); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js index efacb89e7..432df50f8 100644 --- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -1,106 +1,112 @@ -angular.module('portainer.docker') -.controller('ContainersDatatableActionsController', ['$state', 'ContainerService', 'ModalService', 'Notifications', 'HttpRequestHelper', -function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) { - this.startAction = function(selectedItems) { - var successMessage = 'Container successfully started'; - var errorMessage = 'Unable to start container'; - executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); - }; +angular.module('portainer.docker').controller('ContainersDatatableActionsController', [ + '$state', + 'ContainerService', + 'ModalService', + 'Notifications', + 'HttpRequestHelper', + function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) { + this.startAction = function (selectedItems) { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); + }; - this.stopAction = function(selectedItems) { - var successMessage = 'Container successfully stopped'; - var errorMessage = 'Unable to stop container'; - executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); - }; + this.stopAction = function (selectedItems) { + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); + }; - this.restartAction = function(selectedItems) { - var successMessage = 'Container successfully restarted'; - var errorMessage = 'Unable to restart container'; - executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); - }; + this.restartAction = function (selectedItems) { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); + }; - this.killAction = function(selectedItems) { - var successMessage = 'Container successfully killed'; - var errorMessage = 'Unable to kill container'; - executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); - }; + this.killAction = function (selectedItems) { + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); + }; - this.pauseAction = function(selectedItems) { - var successMessage = 'Container successfully paused'; - var errorMessage = 'Unable to pause container'; - executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); - }; + this.pauseAction = function (selectedItems) { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); + }; - this.resumeAction = function(selectedItems) { - var successMessage = 'Container successfully resumed'; - var errorMessage = 'Unable to resume container'; - executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); - }; + this.resumeAction = function (selectedItems) { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); + }; - this.removeAction = function(selectedItems) { - var isOneContainerRunning = false; - for (var i = 0; i < selectedItems.length; i++) { - var container = selectedItems[i]; - if (container.State === 'running') { - isOneContainerRunning = true; - break; + this.removeAction = function (selectedItems) { + var isOneContainerRunning = false; + for (var i = 0; i < selectedItems.length; i++) { + var container = selectedItems[i]; + if (container.State === 'running') { + isOneContainerRunning = true; + break; + } } - } - var title = 'You are about to remove one or more container.'; - if (isOneContainerRunning) { - title = 'You are about to remove one or more running container.'; - } + var title = 'You are about to remove one or more container.'; + if (isOneContainerRunning) { + title = 'You are about to remove one or more running container.'; + } - ModalService.confirmContainerDeletion(title, function (result) { - if(!result) { return; } + ModalService.confirmContainerDeletion(title, function (result) { + if (!result) { + return; + } var cleanVolumes = false; if (result[0]) { cleanVolumes = true; } removeSelectedContainers(selectedItems, cleanVolumes); - } - ); - }; - - function executeActionOnContainerList(containers, action, successMessage, errorMessage) { - var actionCount = containers.length; - angular.forEach(containers, function (container) { - HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); - action(container.Id) - .then(function success() { - Notifications.success(successMessage, container.Names[0]); - }) - .catch(function error(err) { - errorMessage = errorMessage + ':' + container.Names[0]; - Notifications.error('Failure', err, errorMessage); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } }); - }); - } + }; - function removeSelectedContainers(containers, cleanVolumes) { - var actionCount = containers.length; - angular.forEach(containers, function (container) { - HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); - ContainerService.remove(container, cleanVolumes) - .then(function success() { - Notifications.success('Container successfully removed', container.Names[0]); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove container'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } + function executeActionOnContainerList(containers, action, successMessage, errorMessage) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + action(container.Id) + .then(function success() { + Notifications.success(successMessage, container.Names[0]); + }) + .catch(function error(err) { + errorMessage = errorMessage + ':' + container.Names[0]; + Notifications.error('Failure', err, errorMessage); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); }); - }); - } -}]); + } + + function removeSelectedContainers(containers, cleanVolumes) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + ContainerService.remove(container, cleanVolumes) + .then(function success() { + Notifications.success('Container successfully removed', container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + }, +]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index b8bdc3a4b..44aa27c7e 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -2,60 +2,65 @@
-
- {{ $ctrl.titleText }} -
+
{{ $ctrl.titleText }}
- - Columns -
- - - + - - + -
- + @@ -170,7 +188,7 @@ @@ -180,7 +198,17 @@ + Quick actions @@ -199,18 +227,11 @@ - - + + Created - - IP Address - - - - Host @@ -235,39 +256,68 @@
- - + + - {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ + item | containername | truncate: $ctrl.settings.containerNameTruncateSize + }} {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} - {{ item.Status }} - {{ item.Status }} + {{ item.Status }} + {{ item.Status }} + - {{ item.StackName ? item.StackName : '-' }} {{ item.Image | trimshasum }} {{ item.Image | trimshasum }} - {{item.Created | getisodatefromtimestamp}} + {{ item.Created | getisodatefromtimestamp }} {{ item.IP ? item.IP : '-' }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ p.public }}:{{ p.private }} - - + - @@ -286,9 +336,7 @@
@@ -22,11 +35,12 @@
-
@@ -35,7 +49,10 @@
-

Image name is required. Tag must be specified otherwise Portainer will pull all tags associated to the image.

+

Image name is required. + Tag must be specified otherwise Portainer will pull all tags associated to the image.

diff --git a/app/docker/components/imageRegistry/porImageRegistryController.js b/app/docker/components/imageRegistry/porImageRegistryController.js index 7f4e9cfbc..f16902c9d 100644 --- a/app/docker/components/imageRegistry/porImageRegistryController.js +++ b/app/docker/components/imageRegistry/porImageRegistryController.js @@ -38,7 +38,7 @@ class porImageRegistryController { if (this.isKnownRegistry(registry)) { const url = this.getRegistryURL(registry); const registryImages = _.filter(this.images, (image) => _.includes(image, url)); - images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '\/?'), '')); + images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '/?'), '')); } else { const registries = _.filter(this.availableRegistries, (reg) => this.isKnownRegistry(reg)); const registryImages = _.flatMap(registries, (registry) => _.filter(this.images, (image) => _.includes(image, registry.URL))); @@ -64,7 +64,7 @@ class porImageRegistryController { const [registries, dockerhub, images] = await Promise.all([ this.RegistryService.registries(), this.DockerHubService.dockerhub(), - this.autoComplete ? this.ImageService.images() : [] + this.autoComplete ? this.ImageService.images() : [], ]); this.images = this.ImageService.getUniqueTagListFromImages(images); this.availableRegistries = _.concat(dockerhub, registries); @@ -73,7 +73,7 @@ class porImageRegistryController { if (!id) { this.model.Registry = dockerhub; } else { - this.model.Registry = _.find(this.availableRegistries, { 'Id': id }); + this.model.Registry = _.find(this.availableRegistries, { Id: id }); } } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve registries'); diff --git a/app/docker/components/log-viewer/log-viewer.js b/app/docker/components/log-viewer/log-viewer.js index 0be45bc3c..7521916ea 100644 --- a/app/docker/components/log-viewer/log-viewer.js +++ b/app/docker/components/log-viewer/log-viewer.js @@ -6,6 +6,6 @@ angular.module('portainer.docker').component('logViewer', { displayTimestamps: '=', logCollectionChange: '<', sinceTimestamp: '=', - lineCount: '=' - } + lineCount: '=', + }, }); diff --git a/app/docker/components/log-viewer/logViewer.html b/app/docker/components/log-viewer/logViewer.html index 44c1c5d7e..ac9095ebc 100644 --- a/app/docker/components/log-viewer/logViewer.html +++ b/app/docker/components/log-viewer/logViewer.html @@ -11,7 +11,11 @@ @@ -20,9 +24,7 @@ - +
@@ -30,9 +32,7 @@ - +
@@ -51,7 +51,7 @@ Search
- +
@@ -59,7 +59,7 @@ Lines
- +
@@ -67,9 +67,21 @@ Actions
- - - + + + @@ -81,9 +93,9 @@
-
-
-
+
+
+
       

{{ line }}

No log line matching the '{{ $ctrl.state.search }}' filter

No logs available

diff --git a/app/docker/components/log-viewer/logViewerController.js b/app/docker/components/log-viewer/logViewerController.js index 9fdc62663..d55fe555e 100644 --- a/app/docker/components/log-viewer/logViewerController.js +++ b/app/docker/components/log-viewer/logViewerController.js @@ -1,47 +1,47 @@ import moment from 'moment'; -angular.module('portainer.docker') -.controller('LogViewerController', ['clipboard', -function (clipboard) { +angular.module('portainer.docker').controller('LogViewerController', [ + 'clipboard', + function (clipboard) { + this.state = { + availableSinceDatetime: [ + { desc: 'Last day', value: moment().subtract(1, 'days').format() }, + { desc: 'Last 4 hours', value: moment().subtract(4, 'hours').format() }, + { desc: 'Last hour', value: moment().subtract(1, 'hours').format() }, + { desc: 'Last 10 minutes', value: moment().subtract(10, 'minutes').format() }, + ], + copySupported: clipboard.supported, + logCollection: true, + autoScroll: true, + wrapLines: true, + search: '', + filteredLogs: [], + selectedLines: [], + }; - this.state = { - availableSinceDatetime: [ - { desc: 'Last day', value: moment().subtract(1, 'days').format() }, - { desc: 'Last 4 hours', value: moment().subtract(4, 'hours').format() }, - { desc: 'Last hour', value: moment().subtract(1, 'hours').format() }, - { desc: 'Last 10 minutes', value: moment().subtract(10, 'minutes').format() } - ], - copySupported: clipboard.supported, - logCollection: true, - autoScroll: true, - wrapLines: true, - search: '', - filteredLogs: [], - selectedLines: [] - }; + this.copy = function () { + clipboard.copyText(this.state.filteredLogs); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; - this.copy = function() { - clipboard.copyText(this.state.filteredLogs); - $('#refreshRateChange').show(); - $('#refreshRateChange').fadeOut(2000); - }; + this.copySelection = function () { + clipboard.copyText(this.state.selectedLines); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; - this.copySelection = function() { - clipboard.copyText(this.state.selectedLines); - $('#refreshRateChange').show(); - $('#refreshRateChange').fadeOut(2000); - }; + this.clearSelection = function () { + this.state.selectedLines = []; + }; - this.clearSelection = function() { - this.state.selectedLines = []; - }; - - this.selectLine = function(line) { - var idx = this.state.selectedLines.indexOf(line); - if (idx === -1) { - this.state.selectedLines.push(line); - } else { - this.state.selectedLines.splice(idx, 1); - } - }; -}]); + this.selectLine = function (line) { + var idx = this.state.selectedLines.indexOf(line); + if (idx === -1) { + this.state.selectedLines.push(line); + } else { + this.state.selectedLines.splice(idx, 1); + } + }; + }, +]); diff --git a/app/docker/components/network-macvlan-form/network-macvlan-form.js b/app/docker/components/network-macvlan-form/network-macvlan-form.js index d9f07d4c8..3ae345cfb 100644 --- a/app/docker/components/network-macvlan-form/network-macvlan-form.js +++ b/app/docker/components/network-macvlan-form/network-macvlan-form.js @@ -3,6 +3,6 @@ angular.module('portainer.docker').component('networkMacvlanForm', { controller: 'NetworkMacvlanFormController', bindings: { data: '=', - applicationState: '<' - } -}); \ No newline at end of file + applicationState: '<', + }, +}); diff --git a/app/docker/components/network-macvlan-form/networkMacvlanForm.html b/app/docker/components/network-macvlan-form/networkMacvlanForm.html index b104bac9d..fca929aa5 100644 --- a/app/docker/components/network-macvlan-form/networkMacvlanForm.html +++ b/app/docker/components/network-macvlan-form/networkMacvlanForm.html @@ -10,10 +10,10 @@
-
+
- +