diff --git a/.codeclimate.yml b/.codeclimate.yml index 07eb34e8b..32136dcac 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,62 +1,44 @@ version: "2" checks: argument-count: - enabled: true - config: - threshold: 4 + enabled: false complex-logic: - enabled: true - config: - threshold: 4 + enabled: false file-lines: - enabled: true - config: - threshold: 300 + enabled: false method-complexity: enabled: false method-count: - enabled: true - config: - threshold: 20 + enabled: false method-lines: - enabled: true - config: - threshold: 50 + enabled: false nested-control-flow: - enabled: true - config: - threshold: 4 + enabled: false return-statements: enabled: false similar-code: - enabled: true - config: - threshold: #language-specific defaults. overrides affect all languages. + enabled: false identical-code: - enabled: true - config: - threshold: #language-specific defaults. overrides affect all languages. + enabled: false plugins: gofmt: enabled: true - golint: - enabled: true - govet: - enabled: true - csslint: - enabled: true - duplication: - enabled: true - config: - languages: - javascript: - mass_threshold: 80 eslint: enabled: true channel: "eslint-5" config: config: .eslintrc.yml - fixme: - enabled: true exclude_patterns: +- assets/ +- build/ +- dist/ +- distribution/ +- node_modules - test/ +- webpack/ +- gruntfile.js +- webpack.config.js +- api/ +- "!app/kubernetes/**" +- .github/ +- .tmp/ diff --git a/.eslintrc.yml b/.eslintrc.yml index b6829dd81..9a3201f96 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -6,7 +6,6 @@ env: globals: angular: true - __CONFIG_GA_ID: true extends: - 'eslint:recommended' diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index 9005ccd40..00b13e087 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -1,47 +1,48 @@ ---- -name: Bug report -about: Create a bug report - ---- - - - -**Bug description** -A clear and concise description of what the bug is. - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Portainer Logs** -Provide the logs of your Portainer container or Service. -You can see how [here](https://portainer.readthedocs.io/en/stable/faq.html#how-do-i-get-the-logs-from-portainer) - -**Steps to reproduce the issue:** -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Technical details:** -* Portainer version: -* Docker version (managed by Portainer): -* Platform (windows/linux): -* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`): -* Browser: - -**Additional context** -Add any other context about the problem here. +--- +name: Bug report +about: Create a bug report +--- + + + +**Bug description** +A clear and concise description of what the bug is. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Portainer Logs** +Provide the logs of your Portainer container or Service. +You can see how [here](https://portainer.readthedocs.io/en/stable/faq.html#how-do-i-get-the-logs-from-portainer) + +**Steps to reproduce the issue:** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Technical details:** + +- Portainer version: +- Docker version (managed by Portainer): +- Platform (windows/linux): +- Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`): +- Browser: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/stale.yml b/.github/stale.yml index 85d558ed5..45d35a93a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -12,14 +12,13 @@ issues: # Issues with these labels will never be considered stale exemptLabels: - kind/enhancement - - kind/feature - kind/question - kind/style - kind/workaround - bug/need-confirmation - bug/confirmed - status/discuss - + # Only issues with all of these labels are checked if stale. Defaults to `[]` (disabled) onlyLabels: [] @@ -35,9 +34,9 @@ issues: # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > - This issue has been marked as stale as it has not had recent activity, + This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. - If you believe that it has been incorrectly labelled as stale, + If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed. # Comment to post when removing the stale label. @@ -49,7 +48,6 @@ issues: Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment and mention @itsconquest. One of our staff will then review the issue. - + Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed. - \ No newline at end of file diff --git a/.gitignore b/.gitignore index d9c64e8bc..d0ac052cc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ dist portainer-checksum.txt api/cmd/portainer/portainer* .tmp -.vscode -.eslintcache \ No newline at end of file +**/.vscode/settings.json +**/.vscode/tasks.json + +.eslintcache diff --git a/.vscode/portainer.code-snippets b/.vscode/portainer.code-snippets new file mode 100644 index 000000000..6f622dc6b --- /dev/null +++ b/.vscode/portainer.code-snippets @@ -0,0 +1,162 @@ +{ + // Place your portainer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "Component": { + "scope": "javascript", + "prefix": "mycomponent", + "description": "Dummy Angularjs Component", + "body": [ + "import angular from 'angular';", + "import ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}Controller from './${TM_FILENAME_BASE}Controller'", + "", + "angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').component('$TM_FILENAME_BASE', {", + " templateUrl: './$TM_FILENAME_BASE.html',", + " controller: ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/}Controller,", + "});", + "" + ] + }, + "Controller": { + "scope": "javascript", + "prefix": "mycontroller", + "body": [ + "class ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/} {", + "\t/* @ngInject */", + "\tconstructor($0) {", + "\t}", + "}", + "", + "export default ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/};" + ], + "description": "Dummy ES6+ controller" + }, + "Model": { + "scope": "javascript", + "prefix": "mymodel", + "description": "Dummy ES6+ model", + "body": [ + "/**", + " * $1 Model", + " */", + "const _$1 = Object.freeze({", + " $0", + "});", + "", + "export class $1 {", + " constructor() {", + " Object.assign(this, JSON.parse(JSON.stringify(_$1)));", + " }", + "}" + ] + }, + "Service": { + "scope": "javascript", + "prefix": "myservice", + "description": "Dummy ES6+ service", + "body": [ + "import angular from 'angular';", + "import PortainerError from 'Portainer/error';", + "", + "class $1 {", + " /* @ngInject */", + " constructor(\\$async, $0) {", + " this.\\$async = \\$async;", + "", + " this.getAsync = this.getAsync.bind(this);", + " this.getAllAsync = this.getAllAsync.bind(this);", + " this.createAsync = this.createAsync.bind(this);", + " this.updateAsync = this.updateAsync.bind(this);", + " this.deleteAsync = this.deleteAsync.bind(this);", + " }", + "", + " /**", + " * GET", + " */", + " async getAsync() {", + " try {", + "", + " } catch (err) {", + " throw new PortainerError('', err);", + " }", + " }", + "", + " async getAllAsync() {", + " try {", + "", + " } catch (err) {", + " throw new PortainerError('', err);", + " }", + " }", + "", + " get() {", + " if () {", + " return this.\\$async(this.getAsync);", + " }", + " return this.\\$async(this.getAllAsync);", + " }", + "", + " /**", + " * CREATE", + " */", + " async createAsync() {", + " try {", + "", + " } catch (err) {", + " throw new PortainerError('', err);", + " }", + " }", + "", + " create() {", + " return this.\\$async(this.createAsync);", + " }", + "", + " /**", + " * UPDATE", + " */", + " async updateAsync() {", + " try {", + "", + " } catch (err) {", + " throw new PortainerError('', err);", + " }", + " }", + "", + " update() {", + " return this.\\$async(this.updateAsync);", + " }", + "", + " /**", + " * DELETE", + " */", + " async deleteAsync() {", + " try {", + "", + " } catch (err) {", + " throw new PortainerError('', err);", + " }", + " }", + "", + " delete() {", + " return this.\\$async(this.deleteAsync);", + " }", + "}", + "", + "export default $1;", + "angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').service('$1', $1);" + ] + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3f4d5a37..537ae511f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,10 @@ Some basic conventions for contributing to this project. Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork. -* Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring -* Develop in a topic branch, not master/develop +- Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring +- Develop in a topic branch, not master/develop -When creating a new branch, prefix it with the *type* of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator). +When creating a new branch, prefix it with the _type_ of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator). For example, if you work on a bugfix for the issue #361, you could name the branch `fix361-template-selection`. @@ -37,14 +37,14 @@ Lines should not exceed 100 characters. This allows the message to be easier to Must be one of the following: -* **feat**: A new feature -* **fix**: A bug fix -* **docs**: Documentation only changes -* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing +- **feat**: A new feature +- **fix**: A bug fix +- **docs**: Documentation only changes +- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) -* **refactor**: A code change that neither fixes a bug or adds a feature -* **test**: Adding missing tests -* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation +- **refactor**: A code change that neither fixes a bug or adds a feature +- **test**: Adding missing tests +- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation ### Scope @@ -57,9 +57,9 @@ You can use the **area** label tag associated on the issue here (for `area/conta The subject contains succinct description of the change: -* use the imperative, present tense: "change" not "changed" nor "changes" -* don't capitalize first letter -* no dot (.) at the end +- use the imperative, present tense: "change" not "changed" nor "changes" +- don't capitalize first letter +- no dot (.) at the end ## Contribution process diff --git a/README.md b/README.md index 4d5fe4230..825476455 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) diff --git a/api/authorizations.go b/api/authorizations.go deleted file mode 100644 index 2309aec23..000000000 --- a/api/authorizations.go +++ /dev/null @@ -1,795 +0,0 @@ -package portainer - -// AuthorizationService represents a service used to -// update authorizations associated to a user or team. -type AuthorizationService struct { - endpointService EndpointService - endpointGroupService EndpointGroupService - registryService RegistryService - roleService RoleService - teamMembershipService TeamMembershipService - userService UserService -} - -// AuthorizationServiceParameters are the required parameters -// used to create a new AuthorizationService. -type AuthorizationServiceParameters struct { - EndpointService EndpointService - EndpointGroupService EndpointGroupService - RegistryService RegistryService - RoleService RoleService - TeamMembershipService TeamMembershipService - UserService UserService -} - -// NewAuthorizationService returns a point to a new AuthorizationService instance. -func NewAuthorizationService(parameters *AuthorizationServiceParameters) *AuthorizationService { - return &AuthorizationService{ - endpointService: parameters.EndpointService, - endpointGroupService: parameters.EndpointGroupService, - registryService: parameters.RegistryService, - roleService: parameters.RoleService, - teamMembershipService: parameters.TeamMembershipService, - userService: parameters.UserService, - } -} - -// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default endpoint authorizations -// associated to the endpoint administrator role. -func DefaultEndpointAuthorizationsForEndpointAdministratorRole() Authorizations { - return map[Authorization]bool{ - OperationDockerContainerArchiveInfo: true, - OperationDockerContainerList: true, - OperationDockerContainerExport: true, - OperationDockerContainerChanges: true, - OperationDockerContainerInspect: true, - OperationDockerContainerTop: true, - OperationDockerContainerLogs: true, - OperationDockerContainerStats: true, - OperationDockerContainerAttachWebsocket: true, - OperationDockerContainerArchive: true, - OperationDockerContainerCreate: true, - OperationDockerContainerPrune: true, - OperationDockerContainerKill: true, - OperationDockerContainerPause: true, - OperationDockerContainerUnpause: true, - OperationDockerContainerRestart: true, - OperationDockerContainerStart: true, - OperationDockerContainerStop: true, - OperationDockerContainerWait: true, - OperationDockerContainerResize: true, - OperationDockerContainerAttach: true, - OperationDockerContainerExec: true, - OperationDockerContainerRename: true, - OperationDockerContainerUpdate: true, - OperationDockerContainerPutContainerArchive: true, - OperationDockerContainerDelete: true, - OperationDockerImageList: true, - OperationDockerImageSearch: true, - OperationDockerImageGetAll: true, - OperationDockerImageGet: true, - OperationDockerImageHistory: true, - OperationDockerImageInspect: true, - OperationDockerImageLoad: true, - OperationDockerImageCreate: true, - OperationDockerImagePrune: true, - OperationDockerImagePush: true, - OperationDockerImageTag: true, - OperationDockerImageDelete: true, - OperationDockerImageCommit: true, - OperationDockerImageBuild: true, - OperationDockerNetworkList: true, - OperationDockerNetworkInspect: true, - OperationDockerNetworkCreate: true, - OperationDockerNetworkConnect: true, - OperationDockerNetworkDisconnect: true, - OperationDockerNetworkPrune: true, - OperationDockerNetworkDelete: true, - OperationDockerVolumeList: true, - OperationDockerVolumeInspect: true, - OperationDockerVolumeCreate: true, - OperationDockerVolumePrune: true, - OperationDockerVolumeDelete: true, - OperationDockerExecInspect: true, - OperationDockerExecStart: true, - OperationDockerExecResize: true, - OperationDockerSwarmInspect: true, - OperationDockerSwarmUnlockKey: true, - OperationDockerSwarmInit: true, - OperationDockerSwarmJoin: true, - OperationDockerSwarmLeave: true, - OperationDockerSwarmUpdate: true, - OperationDockerSwarmUnlock: true, - OperationDockerNodeList: true, - OperationDockerNodeInspect: true, - OperationDockerNodeUpdate: true, - OperationDockerNodeDelete: true, - OperationDockerServiceList: true, - OperationDockerServiceInspect: true, - OperationDockerServiceLogs: true, - OperationDockerServiceCreate: true, - OperationDockerServiceUpdate: true, - OperationDockerServiceDelete: true, - OperationDockerSecretList: true, - OperationDockerSecretInspect: true, - OperationDockerSecretCreate: true, - OperationDockerSecretUpdate: true, - OperationDockerSecretDelete: true, - OperationDockerConfigList: true, - OperationDockerConfigInspect: true, - OperationDockerConfigCreate: true, - OperationDockerConfigUpdate: true, - OperationDockerConfigDelete: true, - OperationDockerTaskList: true, - OperationDockerTaskInspect: true, - OperationDockerTaskLogs: true, - OperationDockerPluginList: true, - OperationDockerPluginPrivileges: true, - OperationDockerPluginInspect: true, - OperationDockerPluginPull: true, - OperationDockerPluginCreate: true, - OperationDockerPluginEnable: true, - OperationDockerPluginDisable: true, - OperationDockerPluginPush: true, - OperationDockerPluginUpgrade: true, - OperationDockerPluginSet: true, - OperationDockerPluginDelete: true, - OperationDockerSessionStart: true, - OperationDockerDistributionInspect: true, - OperationDockerBuildPrune: true, - OperationDockerBuildCancel: true, - OperationDockerPing: true, - OperationDockerInfo: true, - OperationDockerVersion: true, - OperationDockerEvents: true, - OperationDockerSystem: true, - OperationDockerUndefined: true, - OperationDockerAgentPing: true, - OperationDockerAgentList: true, - OperationDockerAgentHostInfo: true, - OperationDockerAgentBrowseDelete: true, - OperationDockerAgentBrowseGet: true, - OperationDockerAgentBrowseList: true, - OperationDockerAgentBrowsePut: true, - OperationDockerAgentBrowseRename: true, - OperationDockerAgentUndefined: true, - OperationPortainerResourceControlCreate: true, - OperationPortainerResourceControlUpdate: true, - OperationPortainerStackList: true, - OperationPortainerStackInspect: true, - OperationPortainerStackFile: true, - OperationPortainerStackCreate: true, - OperationPortainerStackMigrate: true, - OperationPortainerStackUpdate: true, - OperationPortainerStackDelete: true, - OperationPortainerWebsocketExec: true, - OperationPortainerWebhookList: true, - OperationPortainerWebhookCreate: true, - OperationPortainerWebhookDelete: true, - OperationIntegrationStoridgeAdmin: true, - EndpointResourcesAccess: true, - } -} - -// DefaultEndpointAuthorizationsForHelpDeskRole returns the default endpoint authorizations -// associated to the helpdesk role. -func DefaultEndpointAuthorizationsForHelpDeskRole(volumeBrowsingAuthorizations bool) Authorizations { - authorizations := map[Authorization]bool{ - OperationDockerContainerArchiveInfo: true, - OperationDockerContainerList: true, - OperationDockerContainerChanges: true, - OperationDockerContainerInspect: true, - OperationDockerContainerTop: true, - OperationDockerContainerLogs: true, - OperationDockerContainerStats: true, - OperationDockerImageList: true, - OperationDockerImageSearch: true, - OperationDockerImageGetAll: true, - OperationDockerImageGet: true, - OperationDockerImageHistory: true, - OperationDockerImageInspect: true, - OperationDockerNetworkList: true, - OperationDockerNetworkInspect: true, - OperationDockerVolumeList: true, - OperationDockerVolumeInspect: true, - OperationDockerSwarmInspect: true, - OperationDockerNodeList: true, - OperationDockerNodeInspect: true, - OperationDockerServiceList: true, - OperationDockerServiceInspect: true, - OperationDockerServiceLogs: true, - OperationDockerSecretList: true, - OperationDockerSecretInspect: true, - OperationDockerConfigList: true, - OperationDockerConfigInspect: true, - OperationDockerTaskList: true, - OperationDockerTaskInspect: true, - OperationDockerTaskLogs: true, - OperationDockerPluginList: true, - OperationDockerDistributionInspect: true, - OperationDockerPing: true, - OperationDockerInfo: true, - OperationDockerVersion: true, - OperationDockerEvents: true, - OperationDockerSystem: true, - OperationDockerAgentPing: true, - OperationDockerAgentList: true, - OperationDockerAgentHostInfo: true, - OperationPortainerStackList: true, - OperationPortainerStackInspect: true, - OperationPortainerStackFile: true, - OperationPortainerWebhookList: true, - EndpointResourcesAccess: true, - } - - if volumeBrowsingAuthorizations { - authorizations[OperationDockerAgentBrowseGet] = true - authorizations[OperationDockerAgentBrowseList] = true - } - - return authorizations -} - -// DefaultEndpointAuthorizationsForStandardUserRole returns the default endpoint authorizations -// associated to the standard user role. -func DefaultEndpointAuthorizationsForStandardUserRole(volumeBrowsingAuthorizations bool) Authorizations { - authorizations := map[Authorization]bool{ - OperationDockerContainerArchiveInfo: true, - OperationDockerContainerList: true, - OperationDockerContainerExport: true, - OperationDockerContainerChanges: true, - OperationDockerContainerInspect: true, - OperationDockerContainerTop: true, - OperationDockerContainerLogs: true, - OperationDockerContainerStats: true, - OperationDockerContainerAttachWebsocket: true, - OperationDockerContainerArchive: true, - OperationDockerContainerCreate: true, - OperationDockerContainerKill: true, - OperationDockerContainerPause: true, - OperationDockerContainerUnpause: true, - OperationDockerContainerRestart: true, - OperationDockerContainerStart: true, - OperationDockerContainerStop: true, - OperationDockerContainerWait: true, - OperationDockerContainerResize: true, - OperationDockerContainerAttach: true, - OperationDockerContainerExec: true, - OperationDockerContainerRename: true, - OperationDockerContainerUpdate: true, - OperationDockerContainerPutContainerArchive: true, - OperationDockerContainerDelete: true, - OperationDockerImageList: true, - OperationDockerImageSearch: true, - OperationDockerImageGetAll: true, - OperationDockerImageGet: true, - OperationDockerImageHistory: true, - OperationDockerImageInspect: true, - OperationDockerImageLoad: true, - OperationDockerImageCreate: true, - OperationDockerImagePush: true, - OperationDockerImageTag: true, - OperationDockerImageDelete: true, - OperationDockerImageCommit: true, - OperationDockerImageBuild: true, - OperationDockerNetworkList: true, - OperationDockerNetworkInspect: true, - OperationDockerNetworkCreate: true, - OperationDockerNetworkConnect: true, - OperationDockerNetworkDisconnect: true, - OperationDockerNetworkDelete: true, - OperationDockerVolumeList: true, - OperationDockerVolumeInspect: true, - OperationDockerVolumeCreate: true, - OperationDockerVolumeDelete: true, - OperationDockerExecInspect: true, - OperationDockerExecStart: true, - OperationDockerExecResize: true, - OperationDockerSwarmInspect: true, - OperationDockerSwarmUnlockKey: true, - OperationDockerSwarmInit: true, - OperationDockerSwarmJoin: true, - OperationDockerSwarmLeave: true, - OperationDockerSwarmUpdate: true, - OperationDockerSwarmUnlock: true, - OperationDockerNodeList: true, - OperationDockerNodeInspect: true, - OperationDockerNodeUpdate: true, - OperationDockerNodeDelete: true, - OperationDockerServiceList: true, - OperationDockerServiceInspect: true, - OperationDockerServiceLogs: true, - OperationDockerServiceCreate: true, - OperationDockerServiceUpdate: true, - OperationDockerServiceDelete: true, - OperationDockerSecretList: true, - OperationDockerSecretInspect: true, - OperationDockerSecretCreate: true, - OperationDockerSecretUpdate: true, - OperationDockerSecretDelete: true, - OperationDockerConfigList: true, - OperationDockerConfigInspect: true, - OperationDockerConfigCreate: true, - OperationDockerConfigUpdate: true, - OperationDockerConfigDelete: true, - OperationDockerTaskList: true, - OperationDockerTaskInspect: true, - OperationDockerTaskLogs: true, - OperationDockerPluginList: true, - OperationDockerPluginPrivileges: true, - OperationDockerPluginInspect: true, - OperationDockerPluginPull: true, - OperationDockerPluginCreate: true, - OperationDockerPluginEnable: true, - OperationDockerPluginDisable: true, - OperationDockerPluginPush: true, - OperationDockerPluginUpgrade: true, - OperationDockerPluginSet: true, - OperationDockerPluginDelete: true, - OperationDockerSessionStart: true, - OperationDockerDistributionInspect: true, - OperationDockerBuildPrune: true, - OperationDockerBuildCancel: true, - OperationDockerPing: true, - OperationDockerInfo: true, - OperationDockerVersion: true, - OperationDockerEvents: true, - OperationDockerSystem: true, - OperationDockerUndefined: true, - OperationDockerAgentPing: true, - OperationDockerAgentList: true, - OperationDockerAgentHostInfo: true, - OperationDockerAgentUndefined: true, - OperationPortainerResourceControlUpdate: true, - OperationPortainerStackList: true, - OperationPortainerStackInspect: true, - OperationPortainerStackFile: true, - OperationPortainerStackCreate: true, - OperationPortainerStackMigrate: true, - OperationPortainerStackUpdate: true, - OperationPortainerStackDelete: true, - OperationPortainerWebsocketExec: true, - OperationPortainerWebhookList: true, - OperationPortainerWebhookCreate: true, - } - - if volumeBrowsingAuthorizations { - authorizations[OperationDockerAgentBrowseGet] = true - authorizations[OperationDockerAgentBrowseList] = true - authorizations[OperationDockerAgentBrowseDelete] = true - authorizations[OperationDockerAgentBrowsePut] = true - authorizations[OperationDockerAgentBrowseRename] = true - } - - return authorizations -} - -// DefaultEndpointAuthorizationsForReadOnlyUserRole returns the default endpoint authorizations -// associated to the readonly user role. -func DefaultEndpointAuthorizationsForReadOnlyUserRole(volumeBrowsingAuthorizations bool) Authorizations { - authorizations := map[Authorization]bool{ - OperationDockerContainerArchiveInfo: true, - OperationDockerContainerList: true, - OperationDockerContainerChanges: true, - OperationDockerContainerInspect: true, - OperationDockerContainerTop: true, - OperationDockerContainerLogs: true, - OperationDockerContainerStats: true, - OperationDockerImageList: true, - OperationDockerImageSearch: true, - OperationDockerImageGetAll: true, - OperationDockerImageGet: true, - OperationDockerImageHistory: true, - OperationDockerImageInspect: true, - OperationDockerNetworkList: true, - OperationDockerNetworkInspect: true, - OperationDockerVolumeList: true, - OperationDockerVolumeInspect: true, - OperationDockerSwarmInspect: true, - OperationDockerNodeList: true, - OperationDockerNodeInspect: true, - OperationDockerServiceList: true, - OperationDockerServiceInspect: true, - OperationDockerServiceLogs: true, - OperationDockerSecretList: true, - OperationDockerSecretInspect: true, - OperationDockerConfigList: true, - OperationDockerConfigInspect: true, - OperationDockerTaskList: true, - OperationDockerTaskInspect: true, - OperationDockerTaskLogs: true, - OperationDockerPluginList: true, - OperationDockerDistributionInspect: true, - OperationDockerPing: true, - OperationDockerInfo: true, - OperationDockerVersion: true, - OperationDockerEvents: true, - OperationDockerSystem: true, - OperationDockerAgentPing: true, - OperationDockerAgentList: true, - OperationDockerAgentHostInfo: true, - OperationPortainerStackList: true, - OperationPortainerStackInspect: true, - OperationPortainerStackFile: true, - OperationPortainerWebhookList: true, - } - - if volumeBrowsingAuthorizations { - authorizations[OperationDockerAgentBrowseGet] = true - authorizations[OperationDockerAgentBrowseList] = true - } - - return authorizations -} - -// DefaultPortainerAuthorizations returns the default Portainer authorizations used by non-admin users. -func DefaultPortainerAuthorizations() Authorizations { - return map[Authorization]bool{ - OperationPortainerDockerHubInspect: true, - OperationPortainerEndpointGroupList: true, - OperationPortainerEndpointList: true, - OperationPortainerEndpointInspect: true, - OperationPortainerEndpointExtensionAdd: true, - OperationPortainerEndpointExtensionRemove: true, - OperationPortainerExtensionList: true, - OperationPortainerMOTD: true, - OperationPortainerRegistryList: true, - OperationPortainerRegistryInspect: true, - OperationPortainerTeamList: true, - OperationPortainerTemplateList: true, - OperationPortainerTemplateInspect: true, - OperationPortainerUserList: true, - OperationPortainerUserInspect: true, - OperationPortainerUserMemberships: true, - } -} - -// UpdateVolumeBrowsingAuthorizations will update all the volume browsing authorizations for each role (except endpoint administrator) -// based on the specified removeAuthorizations parameter. If removeAuthorizations is set to true, all -// the authorizations will be dropped for the each role. If removeAuthorizations is set to false, the authorizations -// will be reset based for each role. -func (service AuthorizationService) UpdateVolumeBrowsingAuthorizations(remove bool) error { - roles, err := service.roleService.Roles() - if err != nil { - return err - } - - for _, role := range roles { - // all roles except endpoint administrator - if role.ID != RoleID(1) { - updateRoleVolumeBrowsingAuthorizations(&role, remove) - - err := service.roleService.UpdateRole(role.ID, &role) - if err != nil { - return err - } - } - } - - return nil -} - -func updateRoleVolumeBrowsingAuthorizations(role *Role, removeAuthorizations bool) { - if !removeAuthorizations { - delete(role.Authorizations, OperationDockerAgentBrowseDelete) - delete(role.Authorizations, OperationDockerAgentBrowseGet) - delete(role.Authorizations, OperationDockerAgentBrowseList) - delete(role.Authorizations, OperationDockerAgentBrowsePut) - delete(role.Authorizations, OperationDockerAgentBrowseRename) - return - } - - role.Authorizations[OperationDockerAgentBrowseGet] = true - role.Authorizations[OperationDockerAgentBrowseList] = true - - // Standard-user - if role.ID == RoleID(3) { - role.Authorizations[OperationDockerAgentBrowseDelete] = true - role.Authorizations[OperationDockerAgentBrowsePut] = true - role.Authorizations[OperationDockerAgentBrowseRename] = true - } -} - -// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team -func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) error { - endpoints, err := service.endpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - for policyTeamID := range endpoint.TeamAccessPolicies { - if policyTeamID == teamID { - delete(endpoint.TeamAccessPolicies, policyTeamID) - - err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return err - } - - break - } - } - } - - endpointGroups, err := service.endpointGroupService.EndpointGroups() - if err != nil { - return err - } - - for _, endpointGroup := range endpointGroups { - for policyTeamID := range endpointGroup.TeamAccessPolicies { - if policyTeamID == teamID { - delete(endpointGroup.TeamAccessPolicies, policyTeamID) - - err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) - if err != nil { - return err - } - - break - } - } - } - - registries, err := service.registryService.Registries() - if err != nil { - return err - } - - for _, registry := range registries { - for policyTeamID := range registry.TeamAccessPolicies { - if policyTeamID == teamID { - delete(registry.TeamAccessPolicies, policyTeamID) - - err := service.registryService.UpdateRegistry(registry.ID, ®istry) - if err != nil { - return err - } - - break - } - } - } - - return service.UpdateUsersAuthorizations() -} - -// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user -func (service *AuthorizationService) RemoveUserAccessPolicies(userID UserID) error { - endpoints, err := service.endpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - for policyUserID := range endpoint.UserAccessPolicies { - if policyUserID == userID { - delete(endpoint.UserAccessPolicies, policyUserID) - - err := service.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return err - } - - break - } - } - } - - endpointGroups, err := service.endpointGroupService.EndpointGroups() - if err != nil { - return err - } - - for _, endpointGroup := range endpointGroups { - for policyUserID := range endpointGroup.UserAccessPolicies { - if policyUserID == userID { - delete(endpointGroup.UserAccessPolicies, policyUserID) - - err := service.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) - if err != nil { - return err - } - - break - } - } - } - - registries, err := service.registryService.Registries() - if err != nil { - return err - } - - for _, registry := range registries { - for policyUserID := range registry.UserAccessPolicies { - if policyUserID == userID { - delete(registry.UserAccessPolicies, policyUserID) - - err := service.registryService.UpdateRegistry(registry.ID, ®istry) - if err != nil { - return err - } - - break - } - } - } - - return nil -} - -// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. -func (service *AuthorizationService) UpdateUsersAuthorizations() error { - users, err := service.userService.Users() - if err != nil { - return err - } - - for _, user := range users { - err := service.updateUserAuthorizations(user.ID) - if err != nil { - return err - } - } - - return nil -} - -func (service *AuthorizationService) updateUserAuthorizations(userID UserID) error { - user, err := service.userService.User(userID) - if err != nil { - return err - } - - endpointAuthorizations, err := service.getAuthorizations(user) - if err != nil { - return err - } - - user.EndpointAuthorizations = endpointAuthorizations - - return service.userService.UpdateUser(userID, user) -} - -func (service *AuthorizationService) getAuthorizations(user *User) (EndpointAuthorizations, error) { - endpointAuthorizations := EndpointAuthorizations{} - if user.Role == AdministratorRole { - return endpointAuthorizations, nil - } - - userMemberships, err := service.teamMembershipService.TeamMembershipsByUserID(user.ID) - if err != nil { - return endpointAuthorizations, err - } - - endpoints, err := service.endpointService.Endpoints() - if err != nil { - return endpointAuthorizations, err - } - - endpointGroups, err := service.endpointGroupService.EndpointGroups() - if err != nil { - return endpointAuthorizations, err - } - - roles, err := service.roleService.Roles() - if err != nil { - return endpointAuthorizations, err - } - - endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships) - - return endpointAuthorizations, nil -} - -func getUserEndpointAuthorizations(user *User, endpoints []Endpoint, endpointGroups []EndpointGroup, roles []Role, userMemberships []TeamMembership) EndpointAuthorizations { - endpointAuthorizations := make(EndpointAuthorizations) - - groupUserAccessPolicies := map[EndpointGroupID]UserAccessPolicies{} - groupTeamAccessPolicies := map[EndpointGroupID]TeamAccessPolicies{} - for _, endpointGroup := range endpointGroups { - groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies - groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies - } - - for _, endpoint := range endpoints { - authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - continue - } - - authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) - if len(authorizations) > 0 { - endpointAuthorizations[endpoint.ID] = authorizations - } - } - - return endpointAuthorizations -} - -func getAuthorizationsFromUserEndpointPolicy(user *User, endpoint *Endpoint, roles []Role) Authorizations { - policyRoles := make([]RoleID, 0) - - policy, ok := endpoint.UserAccessPolicies[user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromUserEndpointGroupPolicy(user *User, endpoint *Endpoint, roles []Role, groupAccessPolicies map[EndpointGroupID]UserAccessPolicies) Authorizations { - policyRoles := make([]RoleID, 0) - - policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointPolicies(memberships []TeamMembership, endpoint *Endpoint, roles []Role) Authorizations { - policyRoles := make([]RoleID, 0) - - for _, membership := range memberships { - policy, ok := endpoint.TeamAccessPolicies[membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []TeamMembership, endpoint *Endpoint, roles []Role, groupAccessPolicies map[EndpointGroupID]TeamAccessPolicies) Authorizations { - policyRoles := make([]RoleID, 0) - - for _, membership := range memberships { - policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID] - if ok { - policyRoles = append(policyRoles, policy.RoleID) - } - } - - return getAuthorizationsFromRoles(policyRoles, roles) -} - -func getAuthorizationsFromRoles(roleIdentifiers []RoleID, roles []Role) Authorizations { - var associatedRoles []Role - - for _, id := range roleIdentifiers { - for _, role := range roles { - if role.ID == id { - associatedRoles = append(associatedRoles, role) - break - } - } - } - - var authorizations Authorizations - highestPriority := 0 - for _, role := range associatedRoles { - if role.Priority > highestPriority { - highestPriority = role.Priority - authorizations = role.Authorizations - } - } - - return authorizations -} diff --git a/api/bolt/customtemplate/customtemplate.go b/api/bolt/customtemplate/customtemplate.go new file mode 100644 index 000000000..316af170e --- /dev/null +++ b/api/bolt/customtemplate/customtemplate.go @@ -0,0 +1,96 @@ +package customtemplate + +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 = "customtemplates" +) + +// Service represents a service for managing custom template 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 +} + +// CustomTemplates return an array containing all the custom templates. +func (service *Service) CustomTemplates() ([]portainer.CustomTemplate, error) { + var customTemplates = make([]portainer.CustomTemplate, 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 customTemplate portainer.CustomTemplate + err := internal.UnmarshalObjectWithJsoniter(v, &customTemplate) + if err != nil { + return err + } + customTemplates = append(customTemplates, customTemplate) + } + + return nil + }) + + return customTemplates, err +} + +// CustomTemplate returns an custom template by ID. +func (service *Service) CustomTemplate(ID portainer.CustomTemplateID) (*portainer.CustomTemplate, error) { + var customTemplate portainer.CustomTemplate + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &customTemplate) + if err != nil { + return nil, err + } + + return &customTemplate, nil +} + +// UpdateCustomTemplate updates an custom template. +func (service *Service) UpdateCustomTemplate(ID portainer.CustomTemplateID, customTemplate *portainer.CustomTemplate) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, customTemplate) +} + +// DeleteCustomTemplate deletes an custom template. +func (service *Service) DeleteCustomTemplate(ID portainer.CustomTemplateID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// CreateCustomTemplate assign an ID to a new custom template and saves it. +func (service *Service) CreateCustomTemplate(customTemplate *portainer.CustomTemplate) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(customTemplate) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(customTemplate.ID)), data) + }) +} + +// GetNextIdentifier returns the next identifier for a custom template. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index f80fd5921..9df787d71 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -5,16 +5,17 @@ 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" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/customtemplate" "github.com/portainer/portainer/api/bolt/dockerhub" + "github.com/portainer/portainer/api/bolt/edgegroup" + "github.com/portainer/portainer/api/bolt/edgejob" + "github.com/portainer/portainer/api/bolt/edgestack" "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/errors" "github.com/portainer/portainer/api/bolt/extension" "github.com/portainer/portainer/api/bolt/migrator" "github.com/portainer/portainer/api/bolt/registry" @@ -26,10 +27,11 @@ import ( "github.com/portainer/portainer/api/bolt/tag" "github.com/portainer/portainer/api/bolt/team" "github.com/portainer/portainer/api/bolt/teammembership" - "github.com/portainer/portainer/api/bolt/template" + "github.com/portainer/portainer/api/bolt/tunnelserver" "github.com/portainer/portainer/api/bolt/user" "github.com/portainer/portainer/api/bolt/version" "github.com/portainer/portainer/api/bolt/webhook" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -41,11 +43,12 @@ const ( type Store struct { path string db *bolt.DB - checkForDataMigration bool + isNew bool fileService portainer.FileService - RoleService *role.Service + CustomTemplateService *customtemplate.Service DockerHubService *dockerhub.Service EdgeGroupService *edgegroup.Service + EdgeJobService *edgejob.Service EdgeStackService *edgestack.Service EndpointGroupService *endpointgroup.Service EndpointService *endpoint.Service @@ -53,17 +56,17 @@ type Store struct { 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 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 @@ -71,6 +74,7 @@ func NewStore(storePath string, fileService portainer.FileService) (*Store, erro store := &Store{ path: storePath, fileService: fileService, + isNew: true, } databasePath := path.Join(storePath, databaseFileName) @@ -79,10 +83,8 @@ func NewStore(storePath string, fileService portainer.FileService) (*Store, erro return nil, err } - if !databaseFileExists { - store.checkForDataMigration = false - } else { - store.checkForDataMigration = true + if databaseFileExists { + store.isNew = false } return store, nil @@ -108,14 +110,21 @@ func (store *Store) Close() error { return nil } +// IsNew returns true if the database was just created and false if it is re-using +// existing data. +func (store *Store) IsNew() bool { + return store.isNew +} + // MigrateData automatically migrate the data based on the DBVersion. +// This process is only triggered on an existing database, not if the database was just created. func (store *Store) MigrateData() error { - if !store.checkForDataMigration { + if store.isNew { return store.VersionService.StoreDBVersion(portainer.DBVersion) } version, err := store.VersionService.DBVersion() - if err == portainer.ErrObjectNotFound { + if err == errors.ErrObjectNotFound { version = 0 } else if err != nil { return err @@ -137,10 +146,10 @@ func (store *Store) MigrateData() error { StackService: store.StackService, TagService: store.TagService, TeamMembershipService: store.TeamMembershipService, - TemplateService: store.TemplateService, UserService: store.UserService, VersionService: store.VersionService, FileService: store.fileService, + AuthorizationService: authorization.NewService(store), } migrator := migrator.NewMigrator(migratorParams) @@ -162,6 +171,12 @@ func (store *Store) initServices() error { } store.RoleService = authorizationsetService + customTemplateService, err := customtemplate.NewService(store.db) + if err != nil { + return err + } + store.CustomTemplateService = customTemplateService + dockerhubService, err := dockerhub.NewService(store.db) if err != nil { return err @@ -180,6 +195,12 @@ func (store *Store) initServices() error { } store.EdgeGroupService = edgeGroupService + edgeJobService, err := edgejob.NewService(store.db) + if err != nil { + return err + } + store.EdgeJobService = edgeJobService + endpointgroupService, err := endpointgroup.NewService(store.db) if err != nil { return err @@ -246,12 +267,6 @@ func (store *Store) initServices() error { } store.TeamService = teamService - templateService, err := template.NewService(store.db) - if err != nil { - return err - } - store.TemplateService = templateService - tunnelServerService, err := tunnelserver.NewService(store.db) if err != nil { return err @@ -284,3 +299,103 @@ func (store *Store) initServices() error { return nil } + +// CustomTemplate gives access to the CustomTemplate data management layer +func (store *Store) CustomTemplate() portainer.CustomTemplateService { + return store.CustomTemplateService +} + +// DockerHub gives access to the DockerHub data management layer +func (store *Store) DockerHub() portainer.DockerHubService { + return store.DockerHubService +} + +// EdgeGroup gives access to the EdgeGroup data management layer +func (store *Store) EdgeGroup() portainer.EdgeGroupService { + return store.EdgeGroupService +} + +// EdgeJob gives access to the EdgeJob data management layer +func (store *Store) EdgeJob() portainer.EdgeJobService { + return store.EdgeJobService +} + +// EdgeStack gives access to the EdgeStack data management layer +func (store *Store) EdgeStack() portainer.EdgeStackService { + return store.EdgeStackService +} + +// Endpoint gives access to the Endpoint data management layer +func (store *Store) Endpoint() portainer.EndpointService { + return store.EndpointService +} + +// EndpointGroup gives access to the EndpointGroup data management layer +func (store *Store) EndpointGroup() portainer.EndpointGroupService { + return store.EndpointGroupService +} + +// EndpointRelation gives access to the EndpointRelation data management layer +func (store *Store) EndpointRelation() portainer.EndpointRelationService { + return store.EndpointRelationService +} + +// Registry gives access to the Registry data management layer +func (store *Store) Registry() portainer.RegistryService { + return store.RegistryService +} + +// ResourceControl gives access to the ResourceControl data management layer +func (store *Store) ResourceControl() portainer.ResourceControlService { + return store.ResourceControlService +} + +// Role gives access to the Role data management layer +func (store *Store) Role() portainer.RoleService { + return store.RoleService +} + +// Settings gives access to the Settings data management layer +func (store *Store) Settings() portainer.SettingsService { + return store.SettingsService +} + +// Stack gives access to the Stack data management layer +func (store *Store) Stack() portainer.StackService { + return store.StackService +} + +// Tag gives access to the Tag data management layer +func (store *Store) Tag() portainer.TagService { + return store.TagService +} + +// TeamMembership gives access to the TeamMembership data management layer +func (store *Store) TeamMembership() portainer.TeamMembershipService { + return store.TeamMembershipService +} + +// Team gives access to the Team data management layer +func (store *Store) Team() portainer.TeamService { + return store.TeamService +} + +// TunnelServer gives access to the TunnelServer data management layer +func (store *Store) TunnelServer() portainer.TunnelServerService { + return store.TunnelServerService +} + +// User gives access to the User data management layer +func (store *Store) User() portainer.UserService { + return store.UserService +} + +// Version gives access to the Version data management layer +func (store *Store) Version() portainer.VersionService { + return store.VersionService +} + +// Webhook gives access to the Webhook data management layer +func (store *Store) Webhook() portainer.WebhookService { + return store.WebhookService +} diff --git a/api/bolt/edgejob/edgejob.go b/api/bolt/edgejob/edgejob.go new file mode 100644 index 000000000..f3354c7d8 --- /dev/null +++ b/api/bolt/edgejob/edgejob.go @@ -0,0 +1,101 @@ +package edgejob + +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 = "edgejobs" +) + +// Service represents a service for managing edge jobs 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 +} + +// EdgeJobs returns a list of Edge jobs +func (service *Service) EdgeJobs() ([]portainer.EdgeJob, error) { + var edgeJobs = make([]portainer.EdgeJob, 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 edgeJob portainer.EdgeJob + err := internal.UnmarshalObject(v, &edgeJob) + if err != nil { + return err + } + edgeJobs = append(edgeJobs, edgeJob) + } + + return nil + }) + + return edgeJobs, err +} + +// EdgeJob returns an Edge job by ID +func (service *Service) EdgeJob(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) { + var edgeJob portainer.EdgeJob + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &edgeJob) + if err != nil { + return nil, err + } + + return &edgeJob, nil +} + +// CreateEdgeJob creates a new Edge job +func (service *Service) CreateEdgeJob(edgeJob *portainer.EdgeJob) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + if edgeJob.ID == 0 { + id, _ := bucket.NextSequence() + edgeJob.ID = portainer.EdgeJobID(id) + } + + data, err := internal.MarshalObject(edgeJob) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(edgeJob.ID)), data) + }) +} + +// UpdateEdgeJob updates an Edge job by ID +func (service *Service) UpdateEdgeJob(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, edgeJob) +} + +// DeleteEdgeJob deletes an Edge job +func (service *Service) DeleteEdgeJob(ID portainer.EdgeJobID) 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/errors/errors.go b/api/bolt/errors/errors.go new file mode 100644 index 000000000..c9a142189 --- /dev/null +++ b/api/bolt/errors/errors.go @@ -0,0 +1,7 @@ +package errors + +import "errors" + +var ( + ErrObjectNotFound = errors.New("Object not found inside the database") +) diff --git a/api/bolt/init.go b/api/bolt/init.go index 8e1a0661c..b67c39df0 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -1,9 +1,83 @@ package bolt -import portainer "github.com/portainer/portainer/api" +import ( + "github.com/gofrs/uuid" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" +) // Init creates the default data set. func (store *Store) Init() error { + instanceID, err := store.VersionService.InstanceID() + if err == errors.ErrObjectNotFound { + uid, err := uuid.NewV4() + if err != nil { + return err + } + + instanceID = uid.String() + err = store.VersionService.StoreInstanceID(instanceID) + if err != nil { + return err + } + } else if err != nil { + return err + } + + _, err = store.SettingsService.Settings() + if err == errors.ErrObjectNotFound { + defaultSettings := &portainer.Settings{ + AuthenticationMethod: portainer.AuthenticationInternal, + BlackListedLabels: make([]portainer.Pair, 0), + LDAPSettings: portainer.LDAPSettings{ + AnonymousMode: true, + AutoCreateUsers: true, + TLSConfig: portainer.TLSConfiguration{}, + SearchSettings: []portainer.LDAPSearchSettings{ + portainer.LDAPSearchSettings{}, + }, + GroupSearchSettings: []portainer.LDAPGroupSearchSettings{ + portainer.LDAPGroupSearchSettings{}, + }, + }, + OAuthSettings: portainer.OAuthSettings{}, + AllowBindMountsForRegularUsers: true, + AllowPrivilegedModeForRegularUsers: true, + AllowVolumeBrowserForRegularUsers: false, + AllowHostNamespaceForRegularUsers: true, + AllowDeviceMappingForRegularUsers: true, + AllowStackManagementForRegularUsers: true, + AllowContainerCapabilitiesForRegularUsers: true, + EnableHostManagementFeatures: false, + EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, + TemplatesURL: portainer.DefaultTemplatesURL, + UserSessionTimeout: portainer.DefaultUserSessionTimeout, + } + + err = store.SettingsService.UpdateSettings(defaultSettings) + if err != nil { + return err + } + } else if err != nil { + return err + } + + _, err = store.DockerHubService.DockerHub() + if err == errors.ErrObjectNotFound { + defaultDockerHub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + + err := store.DockerHubService.UpdateDockerHub(defaultDockerHub) + if err != nil { + return err + } + } else if err != nil { + return err + } + groups, err := store.EndpointGroupService.EndpointGroups() if err != nil { return err @@ -25,60 +99,5 @@ func (store *Store) Init() error { } } - roles, err := store.RoleService.Roles() - if err != nil { - return err - } - - if len(roles) == 0 { - environmentAdministratorRole := &portainer.Role{ - Name: "Endpoint administrator", - Description: "Full control of all resources in an endpoint", - Priority: 1, - Authorizations: portainer.DefaultEndpointAuthorizationsForEndpointAdministratorRole(), - } - - err = store.RoleService.CreateRole(environmentAdministratorRole) - if err != nil { - return err - } - - environmentReadOnlyUserRole := &portainer.Role{ - Name: "Helpdesk", - Description: "Read-only access of all resources in an endpoint", - Priority: 2, - Authorizations: portainer.DefaultEndpointAuthorizationsForHelpDeskRole(false), - } - - err = store.RoleService.CreateRole(environmentReadOnlyUserRole) - if err != nil { - return err - } - - standardUserRole := &portainer.Role{ - Name: "Standard user", - Description: "Full control of assigned resources in an endpoint", - Priority: 3, - Authorizations: portainer.DefaultEndpointAuthorizationsForStandardUserRole(false), - } - - err = store.RoleService.CreateRole(standardUserRole) - if err != nil { - return err - } - - readOnlyUserRole := &portainer.Role{ - Name: "Read-only user", - Description: "Read-only access of assigned resources in an endpoint", - Priority: 4, - Authorizations: portainer.DefaultEndpointAuthorizationsForReadOnlyUserRole(false), - } - - err = store.RoleService.CreateRole(readOnlyUserRole) - if err != nil { - return err - } - } - return nil } diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go index 9689fa691..101e1ff00 100644 --- a/api/bolt/internal/db.go +++ b/api/bolt/internal/db.go @@ -4,7 +4,7 @@ import ( "encoding/binary" "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // Itob returns an 8-byte big endian representation of v. @@ -36,7 +36,7 @@ func GetObject(db *bolt.DB, bucketName string, key []byte, object interface{}) e value := bucket.Get(key) if value == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } data = make([]byte, len(value)) diff --git a/api/bolt/migrator/migrate_dbversion0.go b/api/bolt/migrator/migrate_dbversion0.go index 04d1a93b5..1ed54c41d 100644 --- a/api/bolt/migrator/migrate_dbversion0.go +++ b/api/bolt/migrator/migrate_dbversion0.go @@ -3,6 +3,7 @@ package migrator import ( "github.com/boltdb/bolt" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/user" ) @@ -22,7 +23,7 @@ func (m *Migrator) updateAdminUserToDBVersion1() error { if err != nil { return err } - } else if err != nil && err != portainer.ErrObjectNotFound { + } else if err != nil && err != errors.ErrObjectNotFound { return err } return nil diff --git a/api/bolt/migrator/migrate_dbversion14.go b/api/bolt/migrator/migrate_dbversion14.go index 5ec13cd9f..d5a205d4c 100644 --- a/api/bolt/migrator/migrate_dbversion14.go +++ b/api/bolt/migrator/migrate_dbversion14.go @@ -1,11 +1,5 @@ package migrator -import ( - "strings" - - "github.com/portainer/portainer/api" -) - func (m *Migrator) updateSettingsToDBVersion15() error { legacySettings, err := m.settingsService.Settings() if err != nil { @@ -17,19 +11,6 @@ func (m *Migrator) updateSettingsToDBVersion15() error { } func (m *Migrator) updateTemplatesToVersion15() error { - legacyTemplates, err := m.templateService.Templates() - if err != nil { - return err - } - - for _, template := range legacyTemplates { - template.Logo = strings.Replace(template.Logo, "https://portainer.io/images", portainer.AssetsServerURL, -1) - - err = m.templateService.UpdateTemplate(template.ID, &template) - if err != nil { - return err - } - } - + // Removed with the entire template management layer, part of https://github.com/portainer/portainer/issues/3707 return nil } diff --git a/api/bolt/migrator/migrate_dbversion19.go b/api/bolt/migrator/migrate_dbversion19.go index 0692db5af..9f793f41f 100644 --- a/api/bolt/migrator/migrate_dbversion19.go +++ b/api/bolt/migrator/migrate_dbversion19.go @@ -2,22 +2,12 @@ package migrator import ( "strings" - - portainer "github.com/portainer/portainer/api" ) -func (m *Migrator) updateUsersToDBVersion20() error { - authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ - EndpointService: m.endpointService, - EndpointGroupService: m.endpointGroupService, - RegistryService: m.registryService, - RoleService: m.roleService, - TeamMembershipService: m.teamMembershipService, - UserService: m.userService, - } +const scheduleScriptExecutionJobType = 1 - authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) - return authorizationService.UpdateUsersAuthorizations() +func (m *Migrator) updateUsersToDBVersion20() error { + return m.authorizationService.UpdateUsersAuthorizations() } func (m *Migrator) updateSettingsToDBVersion20() error { @@ -38,7 +28,7 @@ func (m *Migrator) updateSchedulesToDBVersion20() error { } for _, schedule := range legacySchedules { - if schedule.JobType == portainer.ScriptExecutionJobType { + if schedule.JobType == scheduleScriptExecutionJobType { if schedule.CronExpression == "0 0 * * *" { schedule.CronExpression = "0 * * * *" } else if schedule.CronExpression == "0 0 0/2 * *" { diff --git a/api/bolt/migrator/migrate_dbversion20.go b/api/bolt/migrator/migrate_dbversion20.go index 1698f10c3..23ddd82ab 100644 --- a/api/bolt/migrator/migrate_dbversion20.go +++ b/api/bolt/migrator/migrate_dbversion20.go @@ -1,6 +1,9 @@ package migrator -import portainer "github.com/portainer/portainer/api" +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" +) func (m *Migrator) updateResourceControlsToDBVersion22() error { legacyResourceControls, err := m.resourceControlService.ResourceControls() @@ -32,7 +35,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error { } for _, user := range legacyUsers { - user.PortainerAuthorizations = portainer.DefaultPortainerAuthorizations() + user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations() err = m.userService.UpdateUser(user.ID, &user) if err != nil { return err @@ -44,7 +47,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error { return err } endpointAdministratorRole.Priority = 1 - endpointAdministratorRole.Authorizations = portainer.DefaultEndpointAuthorizationsForEndpointAdministratorRole() + endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole() err = m.roleService.UpdateRole(endpointAdministratorRole.ID, endpointAdministratorRole) @@ -53,7 +56,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error { return err } helpDeskRole.Priority = 2 - helpDeskRole.Authorizations = portainer.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers) + helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers) err = m.roleService.UpdateRole(helpDeskRole.ID, helpDeskRole) @@ -62,7 +65,7 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error { return err } standardUserRole.Priority = 3 - standardUserRole.Authorizations = portainer.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers) + standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers) err = m.roleService.UpdateRole(standardUserRole.ID, standardUserRole) @@ -71,19 +74,12 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error { return err } readOnlyUserRole.Priority = 4 - readOnlyUserRole.Authorizations = portainer.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers) + readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers) err = m.roleService.UpdateRole(readOnlyUserRole.ID, readOnlyUserRole) - - authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ - EndpointService: m.endpointService, - EndpointGroupService: m.endpointGroupService, - RegistryService: m.registryService, - RoleService: m.roleService, - TeamMembershipService: m.teamMembershipService, - UserService: m.userService, + if err != nil { + return err } - authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) - return authorizationService.UpdateUsersAuthorizations() + return m.authorizationService.UpdateUsersAuthorizations() } diff --git a/api/bolt/migrator/migrate_dbversion23.go b/api/bolt/migrator/migrate_dbversion23.go new file mode 100644 index 000000000..5d1c56904 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion23.go @@ -0,0 +1,34 @@ +package migrator + +import portainer "github.com/portainer/portainer/api" + +func (m *Migrator) updateSettingsToDB24() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowHostNamespaceForRegularUsers = true + legacySettings.AllowDeviceMappingForRegularUsers = true + legacySettings.AllowStackManagementForRegularUsers = true + + return m.settingsService.UpdateSettings(legacySettings) +} + +func (m *Migrator) updateStacksToDB24() error { + stacks, err := m.stackService.Stacks() + if err != nil { + return err + } + + for idx := range stacks { + stack := &stacks[idx] + stack.Status = portainer.StackStatusActive + err := m.stackService.UpdateStack(stack.ID, stack) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator/migrate_dbversion24.go b/api/bolt/migrator/migrate_dbversion24.go new file mode 100644 index 000000000..b4843f2ff --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion24.go @@ -0,0 +1,23 @@ +package migrator + +import ( + "github.com/portainer/portainer/api" +) + +func (m *Migrator) updateSettingsToDB25() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + if legacySettings.TemplatesURL == "" { + legacySettings.TemplatesURL = portainer.DefaultTemplatesURL + } + + legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout + legacySettings.EnableTelemetry = true + + legacySettings.AllowContainerCapabilitiesForRegularUsers = true + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index f9028ccc8..8217dc302 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -15,9 +15,9 @@ import ( "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" "github.com/portainer/portainer/api/bolt/version" + "github.com/portainer/portainer/api/internal/authorization" ) type ( @@ -37,10 +37,10 @@ type ( stackService *stack.Service tagService *tag.Service teamMembershipService *teammembership.Service - templateService *template.Service userService *user.Service versionService *version.Service fileService portainer.FileService + authorizationService *authorization.Service } // Parameters represents the required parameters to create a new Migrator instance. @@ -59,10 +59,10 @@ type ( StackService *stack.Service TagService *tag.Service TeamMembershipService *teammembership.Service - TemplateService *template.Service UserService *user.Service VersionService *version.Service FileService portainer.FileService + AuthorizationService *authorization.Service } ) @@ -82,17 +82,16 @@ func NewMigrator(parameters *Parameters) *Migrator { settingsService: parameters.SettingsService, tagService: parameters.TagService, teamMembershipService: parameters.TeamMembershipService, - templateService: parameters.TemplateService, stackService: parameters.StackService, userService: parameters.UserService, versionService: parameters.VersionService, fileService: parameters.FileService, + authorizationService: parameters.AuthorizationService, } } // Migrate checks the database version and migrate the existing data to the most recent data model. func (m *Migrator) Migrate() error { - // Portainer < 1.12 if m.currentDBVersion < 1 { err := m.updateAdminUserToDBVersion1() @@ -322,5 +321,26 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.24.1 + if m.currentDBVersion < 24 { + err := m.updateSettingsToDB24() + if err != nil { + return err + } + } + + // Portainer 2.0.0 + if m.currentDBVersion < 25 { + err := m.updateSettingsToDB25() + if err != nil { + return err + } + + err = m.updateStacksToDB24() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go index 54d5facd1..a5145ba35 100644 --- a/api/bolt/stack/stack.go +++ b/api/bolt/stack/stack.go @@ -2,6 +2,7 @@ package stack import ( "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -64,7 +65,7 @@ func (service *Service) StackByName(name string) (*portainer.Stack, error) { } if stack == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } return nil diff --git a/api/bolt/team/team.go b/api/bolt/team/team.go index d77aed90e..e88e6a3f8 100644 --- a/api/bolt/team/team.go +++ b/api/bolt/team/team.go @@ -2,6 +2,7 @@ package team import ( "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -64,7 +65,7 @@ func (service *Service) TeamByName(name string) (*portainer.Team, error) { } if team == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } return nil diff --git a/api/bolt/template/template.go b/api/bolt/template/template.go deleted file mode 100644 index e5f7a4cf5..000000000 --- a/api/bolt/template/template.go +++ /dev/null @@ -1,95 +0,0 @@ -package template - -import ( - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/bolt/internal" - - "github.com/boltdb/bolt" -) - -const ( - // BucketName represents the name of the bucket where this service stores data. - BucketName = "templates" -) - -// Service represents a service for managing endpoint data. -type Service struct { - db *bolt.DB -} - -// NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) - if err != nil { - return nil, err - } - - return &Service{ - db: db, - }, nil -} - -// Templates return an array containing all the templates. -func (service *Service) Templates() ([]portainer.Template, error) { - var templates = make([]portainer.Template, 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 template portainer.Template - err := internal.UnmarshalObject(v, &template) - if err != nil { - return err - } - templates = append(templates, template) - } - - return nil - }) - - return templates, err -} - -// Template returns a template by ID. -func (service *Service) Template(ID portainer.TemplateID) (*portainer.Template, error) { - var template portainer.Template - identifier := internal.Itob(int(ID)) - - err := internal.GetObject(service.db, BucketName, identifier, &template) - if err != nil { - return nil, err - } - - return &template, nil -} - -// CreateTemplate creates a new template. -func (service *Service) CreateTemplate(template *portainer.Template) error { - return service.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(BucketName)) - - id, _ := bucket.NextSequence() - template.ID = portainer.TemplateID(id) - - data, err := internal.MarshalObject(template) - if err != nil { - return err - } - - return bucket.Put(internal.Itob(int(template.ID)), data) - }) -} - -// UpdateTemplate saves a template. -func (service *Service) UpdateTemplate(ID portainer.TemplateID, template *portainer.Template) error { - identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, template) -} - -// DeleteTemplate deletes a template. -func (service *Service) DeleteTemplate(ID portainer.TemplateID) error { - identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) -} diff --git a/api/bolt/user/user.go b/api/bolt/user/user.go index 63e3dfdc9..fa0c5c151 100644 --- a/api/bolt/user/user.go +++ b/api/bolt/user/user.go @@ -2,6 +2,7 @@ package user import ( "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -64,7 +65,7 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error) } if user == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } return nil }) diff --git a/api/bolt/version/version.go b/api/bolt/version/version.go index 18e9ab6d5..eca755a57 100644 --- a/api/bolt/version/version.go +++ b/api/bolt/version/version.go @@ -4,14 +4,15 @@ import ( "strconv" "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" ) const ( // BucketName represents the name of the bucket where this service stores data. - BucketName = "version" - versionKey = "DB_VERSION" + BucketName = "version" + versionKey = "DB_VERSION" + instanceKey = "INSTANCE_ID" ) // Service represents a service to manage stored versions. @@ -40,7 +41,7 @@ func (service *Service) DBVersion() (int, error) { value := bucket.Get([]byte(versionKey)) if value == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } data = make([]byte, len(value)) @@ -64,3 +65,37 @@ func (service *Service) StoreDBVersion(version int) error { return bucket.Put([]byte(versionKey), data) }) } + +// InstanceID retrieves the stored instance ID. +func (service *Service) InstanceID() (string, error) { + var data []byte + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + value := bucket.Get([]byte(instanceKey)) + if value == nil { + return errors.ErrObjectNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + + return nil + }) + if err != nil { + return "", err + } + + return string(data), nil +} + +// StoreInstanceID store the instance ID. +func (service *Service) StoreInstanceID(ID string) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data := []byte(ID) + return bucket.Put([]byte(instanceKey), data) + }) +} diff --git a/api/bolt/webhook/webhook.go b/api/bolt/webhook/webhook.go index 38377cdb4..d18900de9 100644 --- a/api/bolt/webhook/webhook.go +++ b/api/bolt/webhook/webhook.go @@ -2,6 +2,7 @@ package webhook import ( "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -87,7 +88,7 @@ func (service *Service) WebhookByResourceID(ID string) (*portainer.Webhook, erro } if webhook == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } return nil @@ -118,7 +119,7 @@ func (service *Service) WebhookByToken(token string) (*portainer.Webhook, error) } if webhook == nil { - return portainer.ErrObjectNotFound + return errors.ErrObjectNotFound } return nil diff --git a/api/chisel/schedules.go b/api/chisel/schedules.go index 39ba9a340..bac424fcb 100644 --- a/api/chisel/schedules.go +++ b/api/chisel/schedules.go @@ -6,42 +6,42 @@ import ( portainer "github.com/portainer/portainer/api" ) -// AddSchedule register a schedule inside the tunnel details associated to an endpoint. -func (service *Service) AddSchedule(endpointID portainer.EndpointID, schedule *portainer.EdgeSchedule) { +// AddEdgeJob register an EdgeJob inside the tunnel details associated to an endpoint. +func (service *Service) AddEdgeJob(endpointID portainer.EndpointID, edgeJob *portainer.EdgeJob) { tunnel := service.GetTunnelDetails(endpointID) - existingScheduleIndex := -1 - for idx, existingSchedule := range tunnel.Schedules { - if existingSchedule.ID == schedule.ID { - existingScheduleIndex = idx + existingJobIndex := -1 + for idx, existingJob := range tunnel.Jobs { + if existingJob.ID == edgeJob.ID { + existingJobIndex = idx break } } - if existingScheduleIndex == -1 { - tunnel.Schedules = append(tunnel.Schedules, *schedule) + if existingJobIndex == -1 { + tunnel.Jobs = append(tunnel.Jobs, *edgeJob) } else { - tunnel.Schedules[existingScheduleIndex] = *schedule + tunnel.Jobs[existingJobIndex] = *edgeJob } key := strconv.Itoa(int(endpointID)) service.tunnelDetailsMap.Set(key, tunnel) } -// RemoveSchedule will remove the specified schedule from each tunnel it was registered with. -func (service *Service) RemoveSchedule(scheduleID portainer.ScheduleID) { +// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with. +func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) { for item := range service.tunnelDetailsMap.IterBuffered() { tunnelDetails := item.Val.(*portainer.TunnelDetails) - updatedSchedules := make([]portainer.EdgeSchedule, 0) - for _, schedule := range tunnelDetails.Schedules { - if schedule.ID == scheduleID { + updatedJobs := make([]portainer.EdgeJob, 0) + for _, edgeJob := range tunnelDetails.Jobs { + if edgeJob.ID == edgeJobID { continue } - updatedSchedules = append(updatedSchedules, schedule) + updatedJobs = append(updatedJobs, edgeJob) } - tunnelDetails.Schedules = updatedSchedules + tunnelDetails.Jobs = updatedJobs service.tunnelDetailsMap.Set(item.Key, tunnelDetails) } } diff --git a/api/chisel/service.go b/api/chisel/service.go index efb08db71..e66983222 100644 --- a/api/chisel/service.go +++ b/api/chisel/service.go @@ -7,11 +7,10 @@ import ( "time" "github.com/dchest/uniuri" - - cmap "github.com/orcaman/concurrent-map" - chserver "github.com/jpillora/chisel/server" - portainer "github.com/portainer/portainer/api" + cmap "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) const ( @@ -24,21 +23,19 @@ const ( // It is used to start a reverse tunnel server and to manage the connection status of each tunnel // connected to the tunnel server. type Service struct { - serverFingerprint string - serverPort string - tunnelDetailsMap cmap.ConcurrentMap - endpointService portainer.EndpointService - tunnelServerService portainer.TunnelServerService - snapshotter portainer.Snapshotter - chiselServer *chserver.Server + serverFingerprint string + serverPort string + tunnelDetailsMap cmap.ConcurrentMap + dataStore portainer.DataStore + snapshotService portainer.SnapshotService + chiselServer *chserver.Server } // NewService returns a pointer to a new instance of Service -func NewService(endpointService portainer.EndpointService, tunnelServerService portainer.TunnelServerService) *Service { +func NewService(dataStore portainer.DataStore) *Service { return &Service{ - tunnelDetailsMap: cmap.New(), - endpointService: endpointService, - tunnelServerService: tunnelServerService, + tunnelDetailsMap: cmap.New(), + dataStore: dataStore, } } @@ -47,7 +44,7 @@ func NewService(endpointService portainer.EndpointService, tunnelServerService p // be found inside the database, it will generate a new one randomly and persist it. // It starts the tunnel status verification process in the background. // The snapshotter is used in the tunnel status verification process. -func (service *Service) StartTunnelServer(addr, port string, snapshotter portainer.Snapshotter) error { +func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error { keySeed, err := service.retrievePrivateKeySeed() if err != nil { return err @@ -80,7 +77,7 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotter portain return err } - service.snapshotter = snapshotter + service.snapshotService = snapshotService go service.startTunnelVerificationLoop() return nil @@ -89,15 +86,15 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotter portain func (service *Service) retrievePrivateKeySeed() (string, error) { var serverInfo *portainer.TunnelServerInfo - serverInfo, err := service.tunnelServerService.Info() - if err == portainer.ErrObjectNotFound { + serverInfo, err := service.dataStore.TunnelServer().Info() + if err == errors.ErrObjectNotFound { keySeed := uniuri.NewLen(16) serverInfo = &portainer.TunnelServerInfo{ PrivateKeySeed: keySeed, } - err := service.tunnelServerService.UpdateInfo(serverInfo) + err := service.dataStore.TunnelServer().UpdateInfo(serverInfo) if err != nil { return "", err } @@ -157,7 +154,7 @@ func (service *Service) checkTunnels() { } } - if len(tunnel.Schedules) > 0 { + if len(tunnel.Jobs) > 0 { endpointID, err := strconv.Atoi(item.Key) if err != nil { log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err) @@ -173,19 +170,19 @@ func (service *Service) checkTunnels() { } func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error { - endpoint, err := service.endpointService.Endpoint(portainer.EndpointID(endpointID)) + endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID) if err != nil { return err } endpointURL := endpoint.URL + endpoint.URL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnelPort) - snapshot, err := service.snapshotter.CreateSnapshot(endpoint) + err = service.snapshotService.SnapshotEndpoint(endpoint) if err != nil { return err } - endpoint.Snapshots = []portainer.Snapshot{*snapshot} endpoint.URL = endpointURL - return service.endpointService.UpdateEndpoint(endpoint.ID, endpoint) + return service.dataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) } diff --git a/api/chisel/tunnel.go b/api/chisel/tunnel.go index e0cba1caf..1306df48c 100644 --- a/api/chisel/tunnel.go +++ b/api/chisel/tunnel.go @@ -47,11 +47,11 @@ func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *porta return tunnelDetails } - schedules := make([]portainer.EdgeSchedule, 0) + jobs := make([]portainer.EdgeJob, 0) return &portainer.TunnelDetails{ Status: portainer.EdgeAgentIdle, Port: 0, - Schedules: schedules, + Jobs: jobs, Credentials: "", } } @@ -97,7 +97,7 @@ func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointI tunnel := service.GetTunnelDetails(endpointID) if tunnel.Port == 0 { - endpoint, err := service.endpointService.Endpoint(endpointID) + endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID) if err != nil { return err } diff --git a/api/cli/cli.go b/api/cli/cli.go index 775aa9242..ca6534893 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "log" "time" @@ -16,16 +17,11 @@ import ( // Service implements the CLIService interface type Service struct{} -const ( - errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://") - errSocketOrNamedPipeNotFound = portainer.Error("Unable to locate Unix socket or named pipe") - errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") - errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk") - errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") - errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval") - errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") - errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file") - errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file") +var ( + errInvalidEndpointProtocol = errors.New("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://") + errSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe") + errInvalidSnapshotInterval = errors.New("Invalid snapshot interval") + errAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file") ) // ParseFlags parse the CLI flags and return a portainer.Flags struct @@ -33,32 +29,28 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { kingpin.Version(version) flags := &portainer.CLIFlags{ - Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), - TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(), - TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), - 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 (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(), - TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), - TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), - TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), - 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 (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 App templates definitions on the filesystem (deprecated)").Default(defaultTemplateFile).String(), + Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), + TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(), + TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), + 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(), + EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(), + NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(), + TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(), + TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(), + TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), + TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), + TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), + 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(), + 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(), } kingpin.Parse() @@ -79,26 +71,7 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { displayDeprecationWarnings(flags) - if *flags.EndpointURL != "" && *flags.ExternalEndpoints != "" { - return errEndpointExcludeExternal - } - - err := validateTemplateFile(*flags.TemplateFile) - if err != nil { - return err - } - - err = validateEndpointURL(*flags.EndpointURL) - if err != nil { - return err - } - - err = validateExternalEndpoints(*flags.ExternalEndpoints) - if err != nil { - return err - } - - err = validateSyncInterval(*flags.SyncInterval) + err := validateEndpointURL(*flags.EndpointURL) if err != nil { return err } @@ -108,10 +81,6 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return err } - if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") { - return errNoAuthExcludeAdminPassword - } - if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" { return errAdminPassExcludeAdminPassFile } @@ -120,24 +89,8 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { } 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.") + if *flags.NoAnalytics { + log.Println("Warning: The --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect.") } } @@ -161,38 +114,6 @@ func validateEndpointURL(endpointURL string) error { return nil } -func validateExternalEndpoints(externalEndpoints string) error { - if externalEndpoints != "" { - if _, err := os.Stat(externalEndpoints); err != nil { - if os.IsNotExist(err) { - return errEndpointsFileNotFound - } - return err - } - } - return nil -} - -func validateTemplateFile(templateFile string) error { - if _, err := os.Stat(templateFile); err != nil { - if os.IsNotExist(err) { - return errTemplateFileNotFound - } - return err - } - return nil -} - -func validateSyncInterval(syncInterval string) error { - if syncInterval != defaultSyncInterval { - _, err := time.ParseDuration(syncInterval) - if err != nil { - return errInvalidSyncInterval - } - } - return nil -} - func validateSnapshotInterval(snapshotInterval string) error { if snapshotInterval != defaultSnapshotInterval { _, err := time.ParseDuration(snapshotInterval) diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 504742771..e52240ebf 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -8,8 +8,6 @@ const ( defaultTunnelServerPort = "8000" defaultDataDirectory = "/data" defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" defaultTLS = "false" defaultTLSSkipVerify = "false" defaultTLSCACertPath = "/certs/ca.pem" @@ -18,8 +16,5 @@ const ( defaultSSL = "false" defaultSSLCertPath = "/certs/portainer.crt" defaultSSLKeyPath = "/certs/portainer.key" - defaultSyncInterval = "60s" - defaultSnapshot = "true" defaultSnapshotInterval = "5m" - defaultTemplateFile = "/templates.json" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 4e7ce7c3e..c7e10f685 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -6,8 +6,6 @@ const ( defaultTunnelServerPort = "8000" defaultDataDirectory = "C:\\data" defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" defaultTLS = "false" defaultTLSSkipVerify = "false" defaultTLSCACertPath = "C:\\certs\\ca.pem" @@ -16,8 +14,5 @@ const ( defaultSSL = "false" defaultSSLCertPath = "C:\\certs\\portainer.crt" defaultSSLKeyPath = "C:\\certs\\portainer.key" - defaultSyncInterval = "60s" - defaultSnapshot = "true" defaultSnapshotInterval = "5m" - defaultTemplateFile = "/templates.json" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index a606fefd4..5d83916be 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -1,18 +1,15 @@ package main import ( - "encoding/json" "log" "os" "strings" "time" - "github.com/portainer/portainer/api/chisel" - "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" + "github.com/portainer/portainer/api/chisel" "github.com/portainer/portainer/api/cli" - "github.com/portainer/portainer/api/cron" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/exec" @@ -20,19 +17,23 @@ import ( "github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/http" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/snapshot" "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/ldap" "github.com/portainer/portainer/api/libcompose" + "github.com/portainer/portainer/api/oauth" ) func initCLI() *portainer.CLIFlags { - var cli portainer.CLIService = &cli.Service{} - flags, err := cli.ParseFlags(portainer.APIVersion) + var cliService portainer.CLIService = &cli.Service{} + flags, err := cliService.ParseFlags(portainer.APIVersion) if err != nil { log.Fatal(err) } - err = cli.ValidateFlags(flags) + err = cliService.ValidateFlags(flags) if err != nil { log.Fatal(err) } @@ -47,7 +48,7 @@ func initFileService(dataStorePath string) portainer.FileService { return fileService } -func initStore(dataStorePath string, fileService portainer.FileService) *bolt.Store { +func initDataStore(dataStorePath string, fileService portainer.FileService) portainer.DataStore { store, err := bolt.NewStore(dataStorePath, fileService) if err != nil { log.Fatal(err) @@ -78,15 +79,21 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService) } -func initJWTService(authenticationEnabled bool) portainer.JWTService { - if authenticationEnabled { - jwtService, err := jwt.NewService() - if err != nil { - log.Fatal(err) - } - return jwtService +func initKubernetesDeployer(assetsPath string) portainer.KubernetesDeployer { + return exec.NewKubernetesDeployer(assetsPath) +} + +func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { + settings, err := dataStore.Settings().Settings() + if err != nil { + return nil, err } - return nil + + jwtService, err := jwt.NewService(settings.UserSessionTimeout) + if err != nil { + return nil, err + } + return jwtService, nil } func initDigitalSignatureService() portainer.DigitalSignatureService { @@ -101,246 +108,75 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } +func initOAuthService() portainer.OAuthService { + return oauth.NewService() +} + func initGitService() portainer.GitService { return git.NewService() } -func initClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { +func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { return docker.NewClientFactory(signatureService, reverseTunnelService) } -func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter { - return docker.NewSnapshotter(clientFactory) +func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory { + return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID) } -func initJobScheduler() portainer.JobScheduler { - return cron.NewJobScheduler() +func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory) (portainer.SnapshotService, error) { + dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory) + kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory) + + snapshotService, err := snapshot.NewService(snapshotInterval, dataStore, dockerSnapshotter, kubernetesSnapshotter) + if err != nil { + return nil, err + } + + return snapshotService, nil } -func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter portainer.Snapshotter, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, settingsService portainer.SettingsService) error { - settings, err := settingsService.Settings() +func loadEdgeJobsFromDatabase(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService) error { + edgeJobs, err := dataStore.EdgeJob().EdgeJobs() if err != nil { return err } - schedules, err := scheduleService.SchedulesByJobType(portainer.SnapshotJobType) - if err != nil { - return err - } - - var snapshotSchedule *portainer.Schedule - if len(schedules) == 0 { - snapshotJob := &portainer.SnapshotJob{} - snapshotSchedule = &portainer.Schedule{ - ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()), - Name: "system_snapshot", - CronExpression: "@every " + settings.SnapshotInterval, - Recurring: true, - JobType: portainer.SnapshotJobType, - SnapshotJob: snapshotJob, - Created: time.Now().Unix(), + for _, edgeJob := range edgeJobs { + for endpointID := range edgeJob.Endpoints { + reverseTunnelService.AddEdgeJob(endpointID, &edgeJob) } - } else { - snapshotSchedule = &schedules[0] - } - - snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter) - snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext) - - err = jobScheduler.ScheduleJob(snapshotJobRunner) - if err != nil { - return err - } - - if len(schedules) == 0 { - return scheduleService.CreateSchedule(snapshotSchedule) - } - return nil -} - -func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, flags *portainer.CLIFlags) error { - if *flags.ExternalEndpoints == "" { - return nil - } - - log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.") - - schedules, err := scheduleService.SchedulesByJobType(portainer.EndpointSyncJobType) - if err != nil { - return err - } - - if len(schedules) != 0 { - return nil - } - - endpointSyncJob := &portainer.EndpointSyncJob{} - - endpointSyncSchedule := &portainer.Schedule{ - ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()), - Name: "system_endpointsync", - CronExpression: "@every " + *flags.SyncInterval, - Recurring: true, - JobType: portainer.EndpointSyncJobType, - EndpointSyncJob: endpointSyncJob, - Created: time.Now().Unix(), - } - - endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints) - endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncSchedule, endpointSyncJobContext) - - err = jobScheduler.ScheduleJob(endpointSyncJobRunner) - if err != nil { - return err - } - - return scheduleService.CreateSchedule(endpointSyncSchedule) -} - -func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) error { - schedules, err := scheduleService.Schedules() - if err != nil { - return err - } - - for _, schedule := range schedules { - - if schedule.JobType == portainer.ScriptExecutionJobType { - jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService) - jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext) - - err = jobScheduler.ScheduleJob(jobRunner) - if err != nil { - return err - } - } - - if schedule.EdgeSchedule != nil { - for _, endpointID := range schedule.EdgeSchedule.Endpoints { - reverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule) - } - } - } return nil } -func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status { +func initStatus(flags *portainer.CLIFlags) *portainer.Status { return &portainer.Status{ - Analytics: !*flags.NoAnalytics, - Authentication: !*flags.NoAuth, - EndpointManagement: endpointManagement, - Snapshot: snapshot, - Version: portainer.APIVersion, + Version: portainer.APIVersion, } } -func initDockerHub(dockerHubService portainer.DockerHubService) error { - _, err := dockerHubService.DockerHub() - if err == portainer.ErrObjectNotFound { - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - return dockerHubService.UpdateDockerHub(dockerhub) - } else if err != nil { - return err - } - - return nil -} - -func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error { - _, err := settingsService.Settings() - if err == portainer.ErrObjectNotFound { - settings := &portainer.Settings{ - LogoURL: *flags.Logo, - AuthenticationMethod: portainer.AuthenticationInternal, - LDAPSettings: portainer.LDAPSettings{ - AnonymousMode: true, - AutoCreateUsers: true, - TLSConfig: portainer.TLSConfiguration{}, - SearchSettings: []portainer.LDAPSearchSettings{ - portainer.LDAPSearchSettings{}, - }, - GroupSearchSettings: []portainer.LDAPGroupSearchSettings{ - portainer.LDAPGroupSearchSettings{}, - }, - }, - OAuthSettings: portainer.OAuthSettings{}, - AllowBindMountsForRegularUsers: true, - AllowPrivilegedModeForRegularUsers: true, - AllowVolumeBrowserForRegularUsers: false, - EnableHostManagementFeatures: false, - SnapshotInterval: *flags.SnapshotInterval, - EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, - } - - if *flags.Templates != "" { - settings.TemplatesURL = *flags.Templates - } - - if *flags.Labels != nil { - settings.BlackListedLabels = *flags.Labels - } else { - settings.BlackListedLabels = make([]portainer.Pair, 0) - } - - return settingsService.UpdateSettings(settings) - } else if err != nil { - return err - } - - return nil -} - -func initTemplates(templateService portainer.TemplateService, fileService portainer.FileService, templateURL, templateFile string) error { - if templateURL != "" { - log.Printf("Portainer started with the --templates flag. Using external templates, template management will be disabled.") - return nil - } - - existingTemplates, err := templateService.Templates() +func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLIFlags) error { + settings, err := dataStore.Settings().Settings() if err != nil { return err } - if len(existingTemplates) != 0 { - log.Printf("Templates already registered inside the database. Skipping template import.") - return nil + settings.LogoURL = *flags.Logo + settings.SnapshotInterval = *flags.SnapshotInterval + settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures + settings.EnableTelemetry = true + + if *flags.Templates != "" { + settings.TemplatesURL = *flags.Templates } - templatesJSON, err := fileService.GetFileContent(templateFile) - if err != nil { - log.Println("Unable to retrieve template definitions via filesystem") - return err + if *flags.Labels != nil { + settings.BlackListedLabels = *flags.Labels } - var templates []portainer.Template - err = json.Unmarshal(templatesJSON, &templates) - if err != nil { - log.Println("Unable to parse templates file. Please review your template definition file.") - return err - } - - for _, template := range templates { - err := templateService.CreateTemplate(&template) - if err != nil { - return err - } - } - - return nil -} - -func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint { - endpoints, err := endpointService.Endpoints() - if err != nil { - log.Fatal(err) - } - return &endpoints[0] + return dataStore.Settings().UpdateSettings(settings) } func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { @@ -372,7 +208,7 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D return generateAndStoreKeyPair(fileService, signatureService) } -func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error { +func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.DataStore, snapshotService portainer.SnapshotService) error { tlsConfiguration := portainer.TLSConfiguration{ TLS: *flags.TLS, TLSSkipVerify: *flags.TLSSkipVerify, @@ -386,7 +222,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain tlsConfiguration.TLS = true } - endpointID := endpointService.GetNextIdentifier() + endpointID := dataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), Name: "primary", @@ -399,7 +235,8 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain Extensions: []portainer.EndpointExtension{}, TagIDs: []portainer.TagID{}, Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), } if strings.HasPrefix(endpoint.URL, "tcp://") { @@ -418,10 +255,15 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain } } - return snapshotAndPersistEndpoint(endpoint, endpointService, snapshotter) + err := snapshotService.SnapshotEndpoint(endpoint) + if err != nil { + log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + } + + return dataStore.Endpoint().CreateEndpoint(endpoint) } -func createUnsecuredEndpoint(endpointURL string, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error { +func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore, snapshotService portainer.SnapshotService) error { if strings.HasPrefix(endpointURL, "tcp://") { _, err := client.ExecutePingOperation(endpointURL, nil) if err != nil { @@ -429,7 +271,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo } } - endpointID := endpointService.GetNextIdentifier() + endpointID := dataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), Name: "primary", @@ -442,32 +284,24 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo Extensions: []portainer.EndpointExtension{}, TagIDs: []portainer.TagID{}, Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), } - return snapshotAndPersistEndpoint(endpoint, endpointService, snapshotter) -} - -func snapshotAndPersistEndpoint(endpoint *portainer.Endpoint, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error { - snapshot, err := snapshotter.CreateSnapshot(endpoint) - endpoint.Status = portainer.EndpointStatusUp + err := snapshotService.SnapshotEndpoint(endpoint) if err != nil { log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) } - if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} - } - - return endpointService.CreateEndpoint(endpoint) + return dataStore.Endpoint().CreateEndpoint(endpoint) } -func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error { +func initEndpoint(flags *portainer.CLIFlags, dataStore portainer.DataStore, snapshotService portainer.SnapshotService) error { if *flags.EndpointURL == "" { return nil } - endpoints, err := endpointService.Endpoints() + endpoints, err := dataStore.Endpoint().Endpoints() if err != nil { return err } @@ -478,31 +312,16 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS } if *flags.TLS || *flags.TLSSkipVerify { - return createTLSSecuredEndpoint(flags, endpointService, snapshotter) + return createTLSSecuredEndpoint(flags, dataStore, snapshotService) } - return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter) + return createUnsecuredEndpoint(*flags.EndpointURL, dataStore, snapshotService) } -func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService { - return docker.NewJobService(dockerClientFactory) -} - -func initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) { - extensionManager := exec.NewExtensionManager(fileService, extensionService) - - err := extensionManager.StartExtensions() - if err != nil { - return nil, err - } - - return extensionManager, nil -} - -func terminateIfNoAdminCreated(userService portainer.UserService) { +func terminateIfNoAdminCreated(dataStore portainer.DataStore) { timer1 := time.NewTimer(5 * time.Minute) <-timer1.C - users, err := userService.UsersByRole(portainer.AdministratorRole) + users, err := dataStore.User().UsersByRole(portainer.AdministratorRole) if err != nil { log.Fatal(err) } @@ -518,41 +337,44 @@ func main() { fileService := initFileService(*flags.Data) - store := initStore(*flags.Data, fileService) - defer store.Close() + dataStore := initDataStore(*flags.Data, fileService) + defer dataStore.Close() - jwtService := initJWTService(!*flags.NoAuth) + jwtService, err := initJWTService(dataStore) + if err != nil { + log.Fatal(err) + } ldapService := initLDAPService() + oauthService := initOAuthService() + gitService := initGitService() cryptoService := initCryptoService() digitalSignatureService := initDigitalSignatureService() - err := initKeyPair(fileService, digitalSignatureService) + err = initKeyPair(fileService, digitalSignatureService) if err != nil { log.Fatal(err) } - extensionManager, err := initExtensionManager(fileService, store.ExtensionService) + reverseTunnelService := chisel.NewService(dataStore) + + instanceID, err := dataStore.Version().InstanceID() if err != nil { log.Fatal(err) } - reverseTunnelService := chisel.NewService(store.EndpointService, store.TunnelServerService) + dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService) + kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID) - clientFactory := initClientFactory(digitalSignatureService, reverseTunnelService) - - jobService := initJobService(clientFactory) - - snapshotter := initSnapshotter(clientFactory) - - endpointManagement := true - if *flags.ExternalEndpoints != "" { - endpointManagement = false + snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory) + if err != nil { + log.Fatal(err) } + snapshotService.Start() swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService) if err != nil { @@ -561,45 +383,23 @@ func main() { composeStackManager := initComposeStackManager(*flags.Data, reverseTunnelService) - err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile) - if err != nil { - log.Fatal(err) - } + kubernetesDeployer := initKubernetesDeployer(*flags.Assets) - err = initSettings(store.SettingsService, flags) - if err != nil { - log.Fatal(err) - } - - jobScheduler := initJobScheduler() - - err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService, reverseTunnelService) - if err != nil { - log.Fatal(err) - } - - err = loadEndpointSyncSystemSchedule(jobScheduler, store.ScheduleService, store.EndpointService, flags) - if err != nil { - log.Fatal(err) - } - - if *flags.Snapshot { - err = loadSnapshotSystemSchedule(jobScheduler, snapshotter, store.ScheduleService, store.EndpointService, store.SettingsService) + if dataStore.IsNew() { + err = updateSettingsFromFlags(dataStore, flags) if err != nil { log.Fatal(err) } } - jobScheduler.Start() - - err = initDockerHub(store.DockerHubService) + err = loadEdgeJobsFromDatabase(dataStore, reverseTunnelService) if err != nil { log.Fatal(err) } - applicationStatus := initStatus(endpointManagement, *flags.Snapshot, flags) + applicationStatus := initStatus(flags) - err = initEndpoint(flags, store.EndpointService, snapshotter) + err = initEndpoint(flags, dataStore, snapshotService) if err != nil { log.Fatal(err) } @@ -619,7 +419,7 @@ func main() { } if adminPasswordHash != "" { - users, err := store.UserService.UsersByRole(portainer.AdministratorRole) + users, err := dataStore.User().UsersByRole(portainer.AdministratorRole) if err != nil { log.Fatal(err) } @@ -627,12 +427,11 @@ func main() { if len(users) == 0 { log.Println("Created admin user with the given password.") user := &portainer.User{ - Username: "admin", - Role: portainer.AdministratorRole, - Password: adminPasswordHash, - PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), + Username: "admin", + Role: portainer.AdministratorRole, + Password: adminPasswordHash, } - err := store.UserService.CreateUser(user) + err := dataStore.User().CreateUser(user) if err != nil { log.Fatal(err) } @@ -641,11 +440,9 @@ func main() { } } - if !*flags.NoAuth { - go terminateIfNoAdminCreated(store.UserService) - } + go terminateIfNoAdminCreated(dataStore) - err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotter) + err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService) if err != nil { log.Fatal(err) } @@ -655,43 +452,23 @@ func main() { 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, + DataStore: dataStore, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, - ExtensionManager: extensionManager, + KubernetesDeployer: kubernetesDeployer, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, LDAPService: ldapService, + OAuthService: oauthService, GitService: gitService, SignatureService: digitalSignatureService, - JobScheduler: jobScheduler, - Snapshotter: snapshotter, + SnapshotService: snapshotService, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, - DockerClientFactory: clientFactory, - JobService: jobService, + DockerClientFactory: dockerClientFactory, + KubernetesClientFactory: kubernetesClientFactory, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/cron/job_endpoint_sync.go b/api/cron/job_endpoint_sync.go deleted file mode 100644 index 361698649..000000000 --- a/api/cron/job_endpoint_sync.go +++ /dev/null @@ -1,214 +0,0 @@ -package cron - -import ( - "encoding/json" - "io/ioutil" - "log" - "strings" - - "github.com/portainer/portainer/api" -) - -// EndpointSyncJobRunner is used to run a EndpointSyncJob -type EndpointSyncJobRunner struct { - schedule *portainer.Schedule - context *EndpointSyncJobContext -} - -// EndpointSyncJobContext represents the context of execution of a EndpointSyncJob -type EndpointSyncJobContext struct { - endpointService portainer.EndpointService - endpointFilePath string -} - -// NewEndpointSyncJobContext returns a new context that can be used to execute a EndpointSyncJob -func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpointFilePath string) *EndpointSyncJobContext { - return &EndpointSyncJobContext{ - endpointService: endpointService, - endpointFilePath: endpointFilePath, - } -} - -// NewEndpointSyncJobRunner returns a new runner that can be scheduled -func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner { - return &EndpointSyncJobRunner{ - schedule: schedule, - context: context, - } -} - -type synchronization struct { - endpointsToCreate []*portainer.Endpoint - endpointsToUpdate []*portainer.Endpoint - endpointsToDelete []*portainer.Endpoint -} - -type fileEndpoint struct { - Name string `json:"Name"` - URL string `json:"URL"` - TLS bool `json:"TLS,omitempty"` - TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"` - TLSCACert string `json:"TLSCACert,omitempty"` - TLSCert string `json:"TLSCert,omitempty"` - TLSKey string `json:"TLSKey,omitempty"` -} - -// GetSchedule returns the schedule associated to the runner -func (runner *EndpointSyncJobRunner) GetSchedule() *portainer.Schedule { - return runner.schedule -} - -// Run triggers the execution of the endpoint synchronization process. -func (runner *EndpointSyncJobRunner) Run() { - data, err := ioutil.ReadFile(runner.context.endpointFilePath) - if endpointSyncError(err) { - return - } - - var fileEndpoints []fileEndpoint - err = json.Unmarshal(data, &fileEndpoints) - if endpointSyncError(err) { - return - } - - if len(fileEndpoints) == 0 { - log.Println("background job error (endpoint synchronization). External endpoint source is empty") - return - } - - storedEndpoints, err := runner.context.endpointService.Endpoints() - if endpointSyncError(err) { - return - } - - convertedFileEndpoints := convertFileEndpoints(fileEndpoints) - - sync := prepareSyncData(storedEndpoints, convertedFileEndpoints) - if sync.requireSync() { - err = runner.context.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) - if endpointSyncError(err) { - return - } - log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete)) - } -} - -func endpointSyncError(err error) bool { - if err != nil { - log.Printf("background job error (endpoint synchronization). Unable to synchronize endpoints (err=%s)\n", err) - return true - } - return false -} - -func isValidEndpoint(endpoint *portainer.Endpoint) bool { - if endpoint.Name != "" && endpoint.URL != "" { - if !strings.HasPrefix(endpoint.URL, "unix://") && !strings.HasPrefix(endpoint.URL, "tcp://") { - return false - } - return true - } - return false -} - -func convertFileEndpoints(fileEndpoints []fileEndpoint) []portainer.Endpoint { - convertedEndpoints := make([]portainer.Endpoint, 0) - - for _, e := range fileEndpoints { - endpoint := portainer.Endpoint{ - Name: e.Name, - URL: e.URL, - TLSConfig: portainer.TLSConfiguration{}, - } - if e.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = e.TLSSkipVerify - endpoint.TLSConfig.TLSCACertPath = e.TLSCACert - endpoint.TLSConfig.TLSCertPath = e.TLSCert - endpoint.TLSConfig.TLSKeyPath = e.TLSKey - } - convertedEndpoints = append(convertedEndpoints, endpoint) - } - - return convertedEndpoints -} - -func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int { - for idx, v := range endpoints { - if endpoint.Name == v.Name && isValidEndpoint(&v) { - return idx - } - } - return -1 -} - -func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint { - var endpoint *portainer.Endpoint - if original.URL != updated.URL || original.TLSConfig.TLS != updated.TLSConfig.TLS || - (updated.TLSConfig.TLS && original.TLSConfig.TLSSkipVerify != updated.TLSConfig.TLSSkipVerify) || - (updated.TLSConfig.TLS && original.TLSConfig.TLSCACertPath != updated.TLSConfig.TLSCACertPath) || - (updated.TLSConfig.TLS && original.TLSConfig.TLSCertPath != updated.TLSConfig.TLSCertPath) || - (updated.TLSConfig.TLS && original.TLSConfig.TLSKeyPath != updated.TLSConfig.TLSKeyPath) { - endpoint = original - endpoint.URL = updated.URL - if updated.TLSConfig.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = updated.TLSConfig.TLSSkipVerify - endpoint.TLSConfig.TLSCACertPath = updated.TLSConfig.TLSCACertPath - endpoint.TLSConfig.TLSCertPath = updated.TLSConfig.TLSCertPath - endpoint.TLSConfig.TLSKeyPath = updated.TLSConfig.TLSKeyPath - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - } - } - return endpoint -} - -func (sync synchronization) requireSync() bool { - if len(sync.endpointsToCreate) != 0 || len(sync.endpointsToUpdate) != 0 || len(sync.endpointsToDelete) != 0 { - return true - } - return false -} - -func prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization { - endpointsToCreate := make([]*portainer.Endpoint, 0) - endpointsToUpdate := make([]*portainer.Endpoint, 0) - endpointsToDelete := make([]*portainer.Endpoint, 0) - - for idx := range storedEndpoints { - fidx := endpointExists(&storedEndpoints[idx], fileEndpoints) - if fidx != -1 { - endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx]) - if endpoint != nil { - log.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL) - endpointsToUpdate = append(endpointsToUpdate, endpoint) - } - } else { - log.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL) - endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx]) - } - } - - for idx, endpoint := range fileEndpoints { - if !isValidEndpoint(&endpoint) { - log.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL) - continue - } - sidx := endpointExists(&fileEndpoints[idx], storedEndpoints) - if sidx == -1 { - log.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL) - endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx]) - } - } - - return &synchronization{ - endpointsToCreate: endpointsToCreate, - endpointsToUpdate: endpointsToUpdate, - endpointsToDelete: endpointsToDelete, - } -} diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go deleted file mode 100644 index f9634813f..000000000 --- a/api/cron/job_script_execution.go +++ /dev/null @@ -1,96 +0,0 @@ -package cron - -import ( - "log" - "time" - - "github.com/portainer/portainer/api" -) - -// ScriptExecutionJobRunner is used to run a ScriptExecutionJob -type ScriptExecutionJobRunner struct { - schedule *portainer.Schedule - context *ScriptExecutionJobContext - executedOnce bool -} - -// ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob -type ScriptExecutionJobContext struct { - jobService portainer.JobService - endpointService portainer.EndpointService - fileService portainer.FileService -} - -// NewScriptExecutionJobContext returns a new context that can be used to execute a ScriptExecutionJob -func NewScriptExecutionJobContext(jobService portainer.JobService, endpointService portainer.EndpointService, fileService portainer.FileService) *ScriptExecutionJobContext { - return &ScriptExecutionJobContext{ - jobService: jobService, - endpointService: endpointService, - fileService: fileService, - } -} - -// NewScriptExecutionJobRunner returns a new runner that can be scheduled -func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { - return &ScriptExecutionJobRunner{ - schedule: schedule, - context: context, - executedOnce: false, - } -} - -// Run triggers the execution of the job. -// It will iterate through all the endpoints specified in the context to -// execute the script associated to the job. -func (runner *ScriptExecutionJobRunner) Run() { - if !runner.schedule.Recurring && runner.executedOnce { - return - } - runner.executedOnce = true - - scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath) - if err != nil { - log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err) - return - } - - targets := make([]*portainer.Endpoint, 0) - for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints { - endpoint, err := runner.context.endpointService.Endpoint(endpointID) - if err != nil { - log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err) - return - } - - targets = append(targets, endpoint) - } - - runner.executeAndRetry(targets, scriptFile, 0) -} - -func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) { - retryTargets := make([]*portainer.Endpoint, 0) - - for _, endpoint := range endpoints { - err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule) - if err == portainer.ErrUnableToPingEndpoint { - retryTargets = append(retryTargets, endpoint) - } else if err != nil { - log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err) - } - } - - retryCount++ - if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount { - return - } - - time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second) - - runner.executeAndRetry(retryTargets, script, retryCount) -} - -// GetSchedule returns the schedule associated to the runner -func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule { - return runner.schedule -} diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go deleted file mode 100644 index 458d026c0..000000000 --- a/api/cron/job_snapshot.go +++ /dev/null @@ -1,85 +0,0 @@ -package cron - -import ( - "log" - - "github.com/portainer/portainer/api" -) - -// SnapshotJobRunner is used to run a SnapshotJob -type SnapshotJobRunner struct { - schedule *portainer.Schedule - context *SnapshotJobContext -} - -// SnapshotJobContext represents the context of execution of a SnapshotJob -type SnapshotJobContext struct { - endpointService portainer.EndpointService - snapshotter portainer.Snapshotter -} - -// NewSnapshotJobContext returns a new context that can be used to execute a SnapshotJob -func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *SnapshotJobContext { - return &SnapshotJobContext{ - endpointService: endpointService, - snapshotter: snapshotter, - } -} - -// NewSnapshotJobRunner returns a new runner that can be scheduled -func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner { - return &SnapshotJobRunner{ - schedule: schedule, - context: context, - } -} - -// GetSchedule returns the schedule associated to the runner -func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { - return runner.schedule -} - -// Run triggers the execution of the schedule. -// It will iterate through all the endpoints available in the database to -// create a snapshot of each one of them. -// As a snapshot can be a long process, to avoid any concurrency issue we -// retrieve the latest version of the endpoint right after a snapshot. -func (runner *SnapshotJobRunner) Run() { - go func() { - endpoints, err := runner.context.endpointService.Endpoints() - if err != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) - return - } - - for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment { - continue - } - - snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint) - - latestEndpointReference, err := runner.context.endpointService.Endpoint(endpoint.ID) - if latestEndpointReference == nil { - log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - continue - } - - latestEndpointReference.Status = portainer.EndpointStatusUp - if snapshotError != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) - latestEndpointReference.Status = portainer.EndpointStatusDown - } - - if snapshot != nil { - latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} - } - - err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) - if err != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - return - } - } - }() -} diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go deleted file mode 100644 index 870105010..000000000 --- a/api/cron/scheduler.go +++ /dev/null @@ -1,116 +0,0 @@ -package cron - -import ( - "github.com/portainer/portainer/api" - "github.com/robfig/cron/v3" -) - -// JobScheduler represents a service for managing crons -type JobScheduler struct { - cron *cron.Cron -} - -// NewJobScheduler initializes a new service -func NewJobScheduler() *JobScheduler { - return &JobScheduler{ - cron: cron.New(), - } -} - -// ScheduleJob schedules the execution of a job via a runner -func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error { - _, err := scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner) - return err -} - -// UpdateSystemJobSchedule updates the first occurence of the specified -// scheduled job based on the specified job type. -// It does so by re-creating a new cron -// and adding all the existing jobs. It will then re-schedule the new job -// with the update cron expression passed in parameter. -// NOTE: the cron library do not support updating schedules directly -// hence the work-around -func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error { - cronEntries := scheduler.cron.Entries() - newCron := cron.New() - - for _, entry := range cronEntries { - if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType { - _, err := newCron.AddJob(newCronExpression, entry.Job) - if err != nil { - return err - } - continue - } - - newCron.Schedule(entry.Schedule, entry.Job) - } - - scheduler.cron.Stop() - scheduler.cron = newCron - scheduler.cron.Start() - return nil -} - -// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron -// and adding all the existing jobs. It will then re-schedule the new job -// via the specified JobRunner parameter. -// NOTE: the cron library do not support updating schedules directly -// hence the work-around -func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error { - cronEntries := scheduler.cron.Entries() - newCron := cron.New() - - for _, entry := range cronEntries { - - if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID { - - var jobRunner cron.Job = runner - if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType { - jobRunner = entry.Job - } - - _, err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner) - if err != nil { - return err - } - continue - } - - newCron.Schedule(entry.Schedule, entry.Job) - } - - scheduler.cron.Stop() - scheduler.cron = newCron - scheduler.cron.Start() - return nil -} - -// UnscheduleJob remove a scheduled job by re-creating a new cron -// and adding all the existing jobs except for the one specified via scheduleID. -// NOTE: the cron library do not support removing schedules directly -// hence the work-around -func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) { - cronEntries := scheduler.cron.Entries() - newCron := cron.New() - - for _, entry := range cronEntries { - - if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID { - continue - } - - newCron.Schedule(entry.Schedule, entry.Job) - } - - scheduler.cron.Stop() - scheduler.cron = newCron - scheduler.cron.Start() -} - -// Start starts the scheduled jobs -func (scheduler *JobScheduler) Start() { - if len(scheduler.cron.Entries()) > 0 { - scheduler.cron.Start() - } -} diff --git a/api/crypto/tls.go b/api/crypto/tls.go index 641aed142..e46998898 100644 --- a/api/crypto/tls.go +++ b/api/crypto/tls.go @@ -6,6 +6,24 @@ import ( "io/ioutil" ) +// CreateServerTLSConfiguration creates a basic tls.Config to be used by servers with recommended TLS settings +func CreateServerTLSConfiguration() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + }, + } +} + // CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key // loaded from memory. func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { diff --git a/api/docker/client.go b/api/docker/client.go index c1bd7a8d0..dace4800d 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -1,6 +1,7 @@ package docker import ( + "errors" "fmt" "net/http" "strings" @@ -11,8 +12,9 @@ import ( "github.com/portainer/portainer/api/crypto" ) +var errUnsupportedEnvironmentType = errors.New("Environment not supported") + const ( - unsupportedEnvironmentType = portainer.Error("Environment not supported") defaultDockerRequestTimeout = 60 dockerClientVersion = "1.37" ) @@ -31,15 +33,15 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers } } -// CreateClient is a generic function to create a Docker client based on +// createClient is a generic function to create a Docker client based on // a specific endpoint configuration. The nodeName parameter can be used // with an agent enabled endpoint to target a specific node in an agent cluster. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { if endpoint.Type == portainer.AzureEnvironment { - return nil, unsupportedEnvironmentType + return nil, errUnsupportedEnvironmentType } else if endpoint.Type == portainer.AgentOnDockerEnvironment { return createAgentClient(endpoint, factory.signatureService, nodeName) - } else if endpoint.Type == portainer.EdgeAgentEnvironment { + } else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName) } diff --git a/api/docker/errors.go b/api/docker/errors.go new file mode 100644 index 000000000..80611c8b4 --- /dev/null +++ b/api/docker/errors.go @@ -0,0 +1,8 @@ +package docker + +import "errors" + +// Docker errors +var ( + ErrUnableToPingEndpoint = errors.New("Unable to communicate with the endpoint") +) diff --git a/api/docker/job.go b/api/docker/job.go deleted file mode 100644 index ff6dae2c2..000000000 --- a/api/docker/job.go +++ /dev/null @@ -1,115 +0,0 @@ -package docker - -import ( - "bytes" - "context" - "io" - "io/ioutil" - "strconv" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/api/types/strslice" - "github.com/docker/docker/client" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/archive" -) - -// JobService represents a service that handles the execution of jobs -type JobService struct { - dockerClientFactory *ClientFactory -} - -// NewJobService returns a pointer to a new job service -func NewJobService(dockerClientFactory *ClientFactory) *JobService { - return &JobService{ - dockerClientFactory: dockerClientFactory, - } -} - -// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename. -// It will copy the script content specified as a parameter inside a container based on the specified image and execute it. -func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error { - buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700) - if err != nil { - return err - } - - cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName) - if err != nil { - return err - } - defer cli.Close() - - _, err = cli.Ping(context.Background()) - if err != nil { - return portainer.ErrUnableToPingEndpoint - } - - err = pullImage(cli, image) - if err != nil { - return err - } - - containerConfig := &container.Config{ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - WorkingDir: "/tmp", - Image: image, - Labels: map[string]string{ - "io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)), - }, - Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}), - } - - if schedule != nil { - containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID)) - } - - hostConfig := &container.HostConfig{ - Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"}, - NetworkMode: "host", - Privileged: true, - } - - networkConfig := &network.NetworkingConfig{} - - body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "") - if err != nil { - return err - } - - if schedule != nil { - err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID) - if err != nil { - return err - } - } - - copyOptions := types.CopyToContainerOptions{} - err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions) - if err != nil { - return err - } - - startOptions := types.ContainerStartOptions{} - return cli.ContainerStart(context.Background(), body.ID, startOptions) -} - -func pullImage(cli *client.Client, image string) error { - imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{}) - if err != nil { - return err - } - defer imageReadCloser.Close() - - _, err = io.Copy(ioutil.Discard, imageReadCloser) - if err != nil { - return err - } - - return nil -} diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index 0ab887373..f9ead6d3e 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -12,13 +12,36 @@ import ( "github.com/portainer/portainer/api" ) -func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { +// Snapshotter represents a service used to create endpoint snapshots +type Snapshotter struct { + clientFactory *ClientFactory +} + +// NewSnapshotter returns a new Snapshotter instance +func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { + return &Snapshotter{ + clientFactory: clientFactory, + } +} + +// CreateSnapshot creates a snapshot of a specific Docker endpoint +func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) { + cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") + if err != nil { + return nil, err + } + defer cli.Close() + + return snapshot(cli, endpoint) +} + +func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) { _, err := cli.Ping(context.Background()) if err != nil { return nil, err } - snapshot := &portainer.Snapshot{ + snapshot := &portainer.DockerSnapshot{ StackCount: 0, } @@ -68,7 +91,7 @@ func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.Snap return snapshot, nil } -func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotInfo(snapshot *portainer.DockerSnapshot, cli *client.Client) error { info, err := cli.Info(context.Background()) if err != nil { return err @@ -82,7 +105,7 @@ func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error { return nil } -func snapshotNodes(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) error { nodes, err := cli.NodeList(context.Background(), types.NodeListOptions{}) if err != nil { return err @@ -98,7 +121,7 @@ func snapshotNodes(snapshot *portainer.Snapshot, cli *client.Client) error { return nil } -func snapshotSwarmServices(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Client) error { stacks := make(map[string]struct{}) services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{}) @@ -119,7 +142,7 @@ func snapshotSwarmServices(snapshot *portainer.Snapshot, cli *client.Client) err return nil } -func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error { containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true}) if err != nil { return err @@ -159,7 +182,7 @@ func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error return nil } -func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotImages(snapshot *portainer.DockerSnapshot, cli *client.Client) error { images, err := cli.ImageList(context.Background(), types.ImageListOptions{}) if err != nil { return err @@ -170,7 +193,7 @@ func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error { return nil } -func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotVolumes(snapshot *portainer.DockerSnapshot, cli *client.Client) error { volumes, err := cli.VolumeList(context.Background(), filters.Args{}) if err != nil { return err @@ -181,7 +204,7 @@ func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error { return nil } -func snapshotNetworks(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotNetworks(snapshot *portainer.DockerSnapshot, cli *client.Client) error { networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{}) if err != nil { return err @@ -190,7 +213,7 @@ func snapshotNetworks(snapshot *portainer.Snapshot, cli *client.Client) error { return nil } -func snapshotVersion(snapshot *portainer.Snapshot, cli *client.Client) error { +func snapshotVersion(snapshot *portainer.DockerSnapshot, cli *client.Client) error { version, err := cli.ServerVersion(context.Background()) if err != nil { return err diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go deleted file mode 100644 index 25eceb023..000000000 --- a/api/docker/snapshotter.go +++ /dev/null @@ -1,28 +0,0 @@ -package docker - -import ( - "github.com/portainer/portainer/api" -) - -// Snapshotter represents a service used to create endpoint snapshots -type Snapshotter struct { - clientFactory *ClientFactory -} - -// NewSnapshotter returns a new Snapshotter instance -func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { - return &Snapshotter{ - clientFactory: clientFactory, - } -} - -// CreateSnapshot creates a snapshot of a specific endpoint -func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { - cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") - if err != nil { - return nil, err - } - defer cli.Close() - - return snapshot(cli, endpoint) -} diff --git a/api/edgegroup.go b/api/edgegroup.go deleted file mode 100644 index d68ee7a9b..000000000 --- a/api/edgegroup.go +++ /dev/null @@ -1,54 +0,0 @@ -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/errors.go b/api/errors.go deleted file mode 100644 index 8e09838a1..000000000 --- a/api/errors.go +++ /dev/null @@ -1,117 +0,0 @@ -package portainer - -// General errors. -const ( - ErrUnauthorized = Error("Unauthorized") - ErrResourceAccessDenied = Error("Access denied to resource") - ErrAuthorizationRequired = Error("Authorization required for this operation") - ErrObjectNotFound = Error("Object not found inside the database") - ErrMissingSecurityContext = Error("Unable to find security details in request context") -) - -// User errors. -const ( - ErrUserAlreadyExists = Error("User already exists") - ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") - ErrAdminAlreadyInitialized = Error("An administrator user already exists") - ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") - ErrCannotRemoveLastLocalAdmin = Error("Cannot remove the last local administrator account") -) - -// Team errors. -const ( - ErrTeamAlreadyExists = Error("Team already exists") -) - -// TeamMembership errors. -const ( - ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team") -) - -// ResourceControl errors. -const ( - ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource") - ErrInvalidResourceControlType = Error("Unsupported resource control type") -) - -// Endpoint errors. -const ( - ErrEndpointAccessDenied = Error("Access denied to endpoint") -) - -// Azure environment errors -const ( - ErrAzureInvalidCredentials = Error("Invalid Azure credentials") -) - -// Endpoint group errors. -const ( - ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") -) - -// Registry errors. -const ( - ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") -) - -// Stack errors -const ( - ErrStackAlreadyExists = Error("A stack already exists with this name") - ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") - ErrStackNotExternal = Error("Not an external stack") -) - -// Tag errors -const ( - ErrTagAlreadyExists = Error("A tag already exists with this name") -) - -// Endpoint extensions error -const ( - ErrEndpointExtensionNotSupported = Error("This extension is not supported") - ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint") -) - -// Crypto errors. -const ( - ErrCryptoHashFailure = Error("Unable to hash data") -) - -// JWT errors. -const ( - ErrSecretGeneration = Error("Unable to generate secret key") - ErrInvalidJWTToken = Error("Invalid JWT token") - ErrMissingContextData = Error("Unable to find JWT data in request context") -) - -// File errors. -const ( - ErrUndefinedTLSFileType = Error("Undefined TLS file type") -) - -// Extension errors. -const ( - ErrExtensionAlreadyEnabled = Error("This extension is already enabled") -) - -// Docker errors. -const ( - ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint") -) - -// Schedule errors. -const ( - ErrHostManagementFeaturesDisabled = Error("Host management features are disabled") -) - -// Error represents an application error. -type Error string - -// Error returns the error message. -func (e Error) Error() string { return string(e) } - -// Webhook errors -const ( - ErrWebhookAlreadyExists = Error("A webhook for this resource already exists") - ErrUnsupportedWebhookType = Error("Webhooks for this resource are not currently supported") -) diff --git a/api/exec/extension.go b/api/exec/extension.go deleted file mode 100644 index e41cf1f49..000000000 --- a/api/exec/extension.go +++ /dev/null @@ -1,313 +0,0 @@ -package exec - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "log" - "os" - "os/exec" - "path" - "regexp" - "runtime" - "strconv" - "strings" - "time" - - "github.com/coreos/go-semver/semver" - - "github.com/orcaman/concurrent-map" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/client" -) - -var extensionDownloadBaseURL = portainer.AssetsServerURL + "/extensions/" -var extensionVersionRegexp = regexp.MustCompile(`\d+(\.\d+)+`) - -var extensionBinaryMap = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "extension-registry-management", - portainer.OAuthAuthenticationExtension: "extension-oauth-authentication", - portainer.RBACExtension: "extension-rbac", -} - -// ExtensionManager represents a service used to -// manage extension processes. -type ExtensionManager struct { - processes cmap.ConcurrentMap - fileService portainer.FileService - extensionService portainer.ExtensionService -} - -// NewExtensionManager returns a pointer to an ExtensionManager -func NewExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) *ExtensionManager { - return &ExtensionManager{ - processes: cmap.New(), - fileService: fileService, - extensionService: extensionService, - } -} - -func processKey(ID portainer.ExtensionID) string { - return strconv.Itoa(int(ID)) -} - -func buildExtensionURL(extension *portainer.Extension) string { - return fmt.Sprintf("%s%s-%s-%s-%s.zip", extensionDownloadBaseURL, extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version) -} - -func buildExtensionPath(binaryPath string, extension *portainer.Extension) string { - extensionFilename := fmt.Sprintf("%s-%s-%s-%s", extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version) - if runtime.GOOS == "windows" { - extensionFilename += ".exe" - } - - extensionPath := path.Join( - binaryPath, - extensionFilename) - - return extensionPath -} - -// FetchExtensionDefinitions will fetch the list of available -// extension definitions from the official Portainer assets server. -// If it cannot retrieve the data from the Internet it will fallback to the locally cached -// manifest file. -func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) { - var extensionData []byte - - extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 5) - if err != nil { - log.Printf("[WARN] [exec,extensions] [message: unable to retrieve extensions manifest via Internet. Extensions will be retrieved from local cache and might not be up to date] [err: %s]", err) - - extensionData, err = manager.fileService.GetFileContent(portainer.LocalExtensionManifestFile) - if err != nil { - return nil, err - } - } - - var extensions []portainer.Extension - err = json.Unmarshal(extensionData, &extensions) - if err != nil { - return nil, err - } - - return extensions, nil -} - -// InstallExtension will install the extension from an archive. It will extract the extension version number from -// the archive file name first and return an error if the file name is not valid (cannot find extension version). -// It will then extract the archive and execute the EnableExtension function to enable the extension. -// Since we're missing information about this extension (stored on Portainer.io server) we need to assume -// default information based on the extension ID. -func (manager *ExtensionManager) InstallExtension(extension *portainer.Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error { - extensionVersion := extensionVersionRegexp.FindString(archiveFileName) - if extensionVersion == "" { - return errors.New("invalid extension archive filename: unable to retrieve extension version") - } - - err := manager.fileService.ExtractExtensionArchive(extensionArchive) - if err != nil { - return err - } - - switch extension.ID { - case portainer.RegistryManagementExtension: - extension.Name = "Registry Manager" - case portainer.OAuthAuthenticationExtension: - extension.Name = "External Authentication" - case portainer.RBACExtension: - extension.Name = "Role-Based Access Control" - } - extension.ShortDescription = "Extension enabled offline" - extension.Version = extensionVersion - extension.Available = true - - return manager.EnableExtension(extension, licenseKey) -} - -// EnableExtension will check for the existence of the extension binary on the filesystem -// first. If it does not exist, it will download it from the official Portainer assets server. -// After installing the binary on the filesystem, it will execute the binary in license check -// mode to validate the extension license. If the license is valid, it will then start -// the extension process and register it in the processes map. -func (manager *ExtensionManager) EnableExtension(extension *portainer.Extension, licenseKey string) error { - extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) - extensionBinaryExists, err := manager.fileService.FileExists(extensionBinaryPath) - if err != nil { - return err - } - - if !extensionBinaryExists { - err := manager.downloadExtension(extension) - if err != nil { - return err - } - } - - licenseDetails, err := validateLicense(extensionBinaryPath, licenseKey) - if err != nil { - return err - } - - extension.License = portainer.LicenseInformation{ - LicenseKey: licenseKey, - Company: licenseDetails[0], - Expiration: licenseDetails[1], - Valid: true, - } - extension.Version = licenseDetails[2] - - return manager.startExtensionProcess(extension, extensionBinaryPath) -} - -// DisableExtension will retrieve the process associated to the extension -// from the processes map and kill the process. It will then remove the process -// from the processes map and remove the binary associated to the extension -// from the filesystem -func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension) error { - process, ok := manager.processes.Get(processKey(extension.ID)) - if !ok { - return nil - } - - err := process.(*exec.Cmd).Process.Kill() - if err != nil { - return err - } - - manager.processes.Remove(processKey(extension.ID)) - - extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) - return manager.fileService.RemoveDirectory(extensionBinaryPath) -} - -// StartExtensions will retrieve the extensions definitions from the Internet and check if a new version of each -// extension is available. If so, it will automatically install the new version of the extension. If no update is -// available it will simply start the extension. -// The purpose of this function is to be ran at startup, as such most of the error handling won't block the program execution -// and will log warning messages instead. -func (manager *ExtensionManager) StartExtensions() error { - extensions, err := manager.extensionService.Extensions() - if err != nil { - return err - } - - definitions, err := manager.FetchExtensionDefinitions() - if err != nil { - log.Printf("[WARN] [exec,extensions] [message: unable to retrieve extension information from Internet. Skipping extensions update check.] [err: %s]", err) - return nil - } - - return manager.updateAndStartExtensions(extensions, definitions) -} - -func (manager *ExtensionManager) updateAndStartExtensions(extensions []portainer.Extension, definitions []portainer.Extension) error { - for _, definition := range definitions { - for _, extension := range extensions { - if extension.ID == definition.ID { - definitionVersion := semver.New(definition.Version) - extensionVersion := semver.New(extension.Version) - - if extensionVersion.LessThan(*definitionVersion) { - log.Printf("[INFO] [exec,extensions] [message: new version detected, updating extension] [extension: %s] [current_version: %s] [available_version: %s]", extension.Name, extension.Version, definition.Version) - err := manager.UpdateExtension(&extension, definition.Version) - if err != nil { - log.Printf("[WARN] [exec,extensions] [message: unable to update extension automatically] [extension: %s] [current_version: %s] [available_version: %s] [err: %s]", extension.Name, extension.Version, definition.Version, err) - } - } else { - err := manager.EnableExtension(&extension, extension.License.LicenseKey) - if err != nil { - log.Printf("[WARN] [exec,extensions] [message: unable to start extension] [extension: %s] [err: %s]", extension.Name, err) - extension.Enabled = false - extension.License.Valid = false - } - } - - err := manager.extensionService.Persist(&extension) - if err != nil { - return err - } - - break - } - } - } - - return nil -} - -// UpdateExtension will download the new extension binary from the official Portainer assets -// server, disable the previous extension via DisableExtension, trigger a license check -// and then start the extension process and add it to the processes map -func (manager *ExtensionManager) UpdateExtension(extension *portainer.Extension, version string) error { - oldVersion := extension.Version - - extension.Version = version - err := manager.downloadExtension(extension) - if err != nil { - return err - } - - extension.Version = oldVersion - err = manager.DisableExtension(extension) - if err != nil { - return err - } - - extension.Version = version - extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) - - licenseDetails, err := validateLicense(extensionBinaryPath, extension.License.LicenseKey) - if err != nil { - return err - } - - extension.Version = licenseDetails[2] - - return manager.startExtensionProcess(extension, extensionBinaryPath) -} - -func (manager *ExtensionManager) downloadExtension(extension *portainer.Extension) error { - extensionURL := buildExtensionURL(extension) - - data, err := client.Get(extensionURL, 30) - if err != nil { - return err - } - - return manager.fileService.ExtractExtensionArchive(data) -} - -func validateLicense(binaryPath, licenseKey string) ([]string, error) { - licenseCheckProcess := exec.Command(binaryPath, "-license", licenseKey, "-check") - cmdOutput := &bytes.Buffer{} - licenseCheckProcess.Stdout = cmdOutput - - err := licenseCheckProcess.Run() - if err != nil { - log.Printf("[DEBUG] [exec,extension] [message: unable to run extension process] [err: %s]", err) - return nil, errors.New("invalid extension license key") - } - - output := string(cmdOutput.Bytes()) - - return strings.Split(output, "|"), nil -} - -func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error { - extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey) - extensionProcess.Stdout = os.Stdout - extensionProcess.Stderr = os.Stderr - - err := extensionProcess.Start() - if err != nil { - log.Printf("[DEBUG] [exec,extension] [message: unable to start extension process] [err: %s]", err) - return err - } - - time.Sleep(3 * time.Second) - - manager.processes.Set(processKey(extension.ID), extensionProcess) - return nil -} diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go new file mode 100644 index 000000000..0330237e0 --- /dev/null +++ b/api/exec/kubernetes_deploy.go @@ -0,0 +1,89 @@ +package exec + +import ( + "bytes" + "errors" + "io/ioutil" + "os/exec" + "path" + "runtime" + "strings" + + portainer "github.com/portainer/portainer/api" +) + +// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment. +type KubernetesDeployer struct { + binaryPath string +} + +// NewKubernetesDeployer initializes a new KubernetesDeployer service. +func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer { + return &KubernetesDeployer{ + binaryPath: binaryPath, + } +} + +// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint. +// If composeFormat is set to true, it will leverage the kompose binary to deploy a compose compliant manifest. +// Otherwise it will use kubectl to deploy the manifest. +func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) { + if composeFormat { + convertedData, err := deployer.convertComposeData(data) + if err != nil { + return nil, err + } + data = string(convertedData) + } + + token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + if err != nil { + return nil, err + } + + command := path.Join(deployer.binaryPath, "kubectl") + if runtime.GOOS == "windows" { + command = path.Join(deployer.binaryPath, "kubectl.exe") + } + + args := make([]string, 0) + args = append(args, "--server", endpoint.URL) + args = append(args, "--insecure-skip-tls-verify") + args = append(args, "--token", string(token)) + args = append(args, "--namespace", namespace) + args = append(args, "apply", "-f", "-") + + var stderr bytes.Buffer + cmd := exec.Command(command, args...) + cmd.Stderr = &stderr + cmd.Stdin = strings.NewReader(data) + + output, err := cmd.Output() + if err != nil { + return nil, errors.New(stderr.String()) + } + + return output, nil +} + +func (deployer *KubernetesDeployer) convertComposeData(data string) ([]byte, error) { + command := path.Join(deployer.binaryPath, "kompose") + if runtime.GOOS == "windows" { + command = path.Join(deployer.binaryPath, "kompose.exe") + } + + args := make([]string, 0) + args = append(args, "convert", "-f", "-", "--stdout") + + var stderr bytes.Buffer + cmd := exec.Command(command, args...) + cmd.Stderr = &stderr + cmd.Stdin = strings.NewReader(data) + + output, err := cmd.Output() + if err != nil { + return nil, errors.New(stderr.String()) + } + + return output, nil +} diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index d5b779a02..31fb48836 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -3,6 +3,7 @@ package exec import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -103,7 +104,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor err := cmd.Run() if err != nil { - return portainer.Error(stderr.String()) + return errors.New(stderr.String()) } return nil @@ -121,7 +122,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa args = append(args, "--config", dataPath) endpointURL := endpoint.URL - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port) } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index d65ad4f8d..5a766f7cc 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -4,10 +4,12 @@ import ( "bytes" "encoding/json" "encoding/pem" + "errors" + "fmt" "io/ioutil" + "github.com/gofrs/uuid" "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/archive" "io" "os" @@ -37,13 +39,20 @@ const ( PublicKeyFile = "portainer.pub" // BinaryStorePath represents the subfolder where binaries are stored in the file store folder. BinaryStorePath = "bin" - // ScheduleStorePath represents the subfolder where schedule files are stored. - ScheduleStorePath = "schedules" + // EdgeJobStorePath represents the subfolder where schedule files are stored. + EdgeJobStorePath = "edge_jobs" // ExtensionRegistryManagementStorePath represents the subfolder where files related to the // registry management extension are stored. ExtensionRegistryManagementStorePath = "extensions" + // CustomTemplateStorePath represents the subfolder where custom template files are stored in the file store folder. + CustomTemplateStorePath = "custom_templates" + // TempPath represent the subfolder where temporary files are saved + TempPath = "tmp" ) +// ErrUndefinedTLSFileType represents an error returned on undefined TLS file type +var ErrUndefinedTLSFileType = errors.New("Undefined TLS file type") + // Service represents a service for managing files and directories. type Service struct { dataStorePath string @@ -86,12 +95,6 @@ func (service *Service) GetBinaryFolder() string { return path.Join(service.fileStorePath, BinaryStorePath) } -// ExtractExtensionArchive extracts the content of an extension archive -// specified as raw data into the binary store on the filesystem -func (service *Service) ExtractExtensionArchive(data []byte) error { - return archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath)) -} - // RemoveDirectory removes a directory on the filesystem. func (service *Service) RemoveDirectory(directoryPath string) error { return os.RemoveAll(directoryPath) @@ -188,7 +191,7 @@ func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer. case portainer.TLSFileKey: fileName = TLSKeyFile default: - return "", portainer.ErrUndefinedTLSFileType + return "", ErrUndefinedTLSFileType } tlsFilePath := path.Join(storePath, fileName) @@ -211,7 +214,7 @@ func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSF case portainer.TLSFileKey: fileName = TLSKeyFile default: - return "", portainer.ErrUndefinedTLSFileType + return "", ErrUndefinedTLSFileType } return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil } @@ -237,7 +240,7 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT case portainer.TLSFileKey: fileName = TLSKeyFile default: - return portainer.ErrUndefinedTLSFileType + return ErrUndefinedTLSFileType } filePath := path.Join(service.fileStorePath, TLSStorePath, folder, fileName) @@ -392,22 +395,48 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { return block.Bytes, nil } -// GetScheduleFolder returns the absolute path on the filesystem for a schedule based +// GetCustomTemplateProjectPath returns the absolute path on the FS for a custom template based // on its identifier. -func (service *Service) GetScheduleFolder(identifier string) string { - return path.Join(service.fileStorePath, ScheduleStorePath, identifier) +func (service *Service) GetCustomTemplateProjectPath(identifier string) string { + return path.Join(service.fileStorePath, CustomTemplateStorePath, identifier) } -// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes. +// StoreCustomTemplateFileFromBytes creates a subfolder in the CustomTemplateStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. -func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) { - scheduleStorePath := path.Join(ScheduleStorePath, identifier) - err := service.createDirectoryInStore(scheduleStorePath) +func (service *Service) StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) { + customTemplateStorePath := path.Join(CustomTemplateStorePath, identifier) + err := service.createDirectoryInStore(customTemplateStorePath) if err != nil { return "", err } - filePath := path.Join(scheduleStorePath, createScheduledJobFileName(identifier)) + templateFilePath := path.Join(customTemplateStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(templateFilePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, customTemplateStorePath), nil +} + +// GetEdgeJobFolder returns the absolute path on the filesystem for an Edge job based +// on its identifier. +func (service *Service) GetEdgeJobFolder(identifier string) string { + return path.Join(service.fileStorePath, EdgeJobStorePath, identifier) +} + +// StoreEdgeJobFileFromBytes creates a subfolder in the EdgeJobStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreEdgeJobFileFromBytes(identifier string, data []byte) (string, error) { + edgeJobStorePath := path.Join(EdgeJobStorePath, identifier) + err := service.createDirectoryInStore(edgeJobStorePath) + if err != nil { + return "", err + } + + filePath := path.Join(edgeJobStorePath, createEdgeJobFileName(identifier)) r := bytes.NewReader(data) err = service.createFileInStore(filePath, r) if err != nil { @@ -417,6 +446,62 @@ func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data [ return path.Join(service.fileStorePath, filePath), nil } -func createScheduledJobFileName(identifier string) string { +func createEdgeJobFileName(identifier string) string { return "job_" + identifier + ".sh" } + +// ClearEdgeJobTaskLogs clears the Edge job task logs +func (service *Service) ClearEdgeJobTaskLogs(edgeJobID string, taskID string) error { + path := service.getEdgeJobTaskLogPath(edgeJobID, taskID) + + err := os.Remove(path) + if err != nil { + return err + } + + return nil +} + +// GetEdgeJobTaskLogFileContent fetches the Edge job task logs +func (service *Service) GetEdgeJobTaskLogFileContent(edgeJobID string, taskID string) (string, error) { + path := service.getEdgeJobTaskLogPath(edgeJobID, taskID) + + fileContent, err := ioutil.ReadFile(path) + if err != nil { + return "", err + } + + return string(fileContent), nil +} + +// StoreEdgeJobTaskLogFileFromBytes stores the log file +func (service *Service) StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID string, data []byte) error { + edgeJobStorePath := path.Join(EdgeJobStorePath, edgeJobID) + err := service.createDirectoryInStore(edgeJobStorePath) + if err != nil { + return err + } + + filePath := path.Join(edgeJobStorePath, fmt.Sprintf("logs_%s", taskID)) + r := bytes.NewReader(data) + err = service.createFileInStore(filePath, r) + if err != nil { + return err + } + + return nil +} + +func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) string { + return fmt.Sprintf("%s/logs_%s", service.GetEdgeJobFolder(edgeJobID), taskID) +} + +// GetTemporaryPath returns a temp folder +func (service *Service) GetTemporaryPath() (string, error) { + uid, err := uuid.NewV4() + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, TempPath, uid.String()), nil +} diff --git a/api/go.mod b/api/go.mod index 773fbff55..fcee3b6a8 100644 --- a/api/go.mod +++ b/api/go.mod @@ -13,6 +13,7 @@ require ( github.com/docker/cli v0.0.0-20191126203649-54d085b857e9 github.com/docker/docker v0.0.0-00010101000000-000000000000 github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 + github.com/go-ldap/ldap/v3 v3.1.8 github.com/gofrs/uuid v3.2.0+incompatible github.com/gorilla/mux v1.7.3 github.com/gorilla/securecookie v1.1.1 @@ -27,14 +28,14 @@ require ( github.com/portainer/libcompose v0.5.3 github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 - github.com/robfig/cron/v3 v3.0.0 golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 + golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 - gopkg.in/asn1-ber.v1 v1.0.0-00010101000000-000000000000 // indirect - gopkg.in/ldap.v2 v2.5.1 gopkg.in/src-d/go-git.v4 v4.13.1 + k8s.io/api v0.17.2 + k8s.io/apimachinery v0.17.2 + k8s.io/client-go v0.17.2 ) replace github.com/docker/docker => github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203 - -replace gopkg.in/asn1-ber.v1 => github.com/go-asn1-ber/asn1-ber v1.3.1 diff --git a/api/go.sum b/api/go.sum index 80c8f01f4..d7b7db557 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,6 +1,15 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.3.8 h1:dvxbxtpTIjdAbx2OtL26p4eq0iEvys/U5yrsTJb3NZI= github.com/Microsoft/go-winio v0.3.8/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= @@ -8,6 +17,9 @@ github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+q github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA= github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= @@ -36,6 +48,7 @@ github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -50,12 +63,8 @@ github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BU github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= -github.com/docker/engine v1.4.2-0.20191127222017-3152f9436292 h1:qQ7mw+CVWpRj5DWBL4CVHtBbGQdlPCj4j1evDh0ethw= -github.com/docker/engine v1.4.2-0.20191127222017-3152f9436292/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203 h1:QeBh8wW8pIZKlXxlMOQ8hSCMdJA+2Z/bD/iDyCAS8XU= github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= -github.com/docker/engine v1.13.1 h1:Cks33UT9YBW5Xyc3MtGDq2IPgqfJtJ+qkFaxc2b0Euc= -github.com/docker/engine v1.13.1/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= github.com/docker/go-connections v0.3.0 h1:3lOnM9cSzgGwx8VfK/NGOW5fLQ0GjIlCkaktF+n1M6o= github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA= @@ -64,36 +73,65 @@ github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 h1:cenwrSVm+Z7QLSV/BsnenAOcDXdX4cMv4wP0B/5QbPg= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck= github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-ldap/ldap/v3 v3.1.8 h1:5vU/2jOh9HqprwXp8aF915s9p6Z8wmbSEVF7/gdTFhM= +github.com/go-ldap/ldap/v3 v3.1.8/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d h1:7XGaL1e6bYS1yIonGp9761ExpPPV1ui0SAC59Yube9k= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v0.0.0-20160317213430-0eeaf8392f5b/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= @@ -103,6 +141,12 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20170728041850-787624de3eb7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -117,13 +161,17 @@ github.com/jpillora/requestlog v0.0.0-20181015073026-df8817be5f82 h1:7ufdyC3aMxF github.com/jpillora/requestlog v0.0.0-20181015073026-df8817be5f82/go.mod h1:w8buj+yNfmLEP0ENlbG/FRnK6bVmuhqXnukYCs9sDvY= github.com/jpillora/sizestr v0.0.0-20160130011556-e2ea2fa42fb9 h1:0c9jcgBtHRtDU//jTrcCgWG6UHjMZytiq/3WhraNgUM= github.com/jpillora/sizestr v0.0.0-20160130011556-e2ea2fa42fb9/go.mod h1:1ffp+CRe0eAwwRb0/BownUAjMBsmTLwgAvRbfj9dRwE= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw= github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= @@ -136,12 +184,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-shellwords v1.0.6 h1:9Jok5pILi5S1MnDirGVTufYGtksUs/V2BWUP3ZkeUUI= github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/microsoft/go-winio v0.4.8 h1:N4SmTFXUK7/jnn/UG/gm2mrHiYu9LVGvtsvULyody/c= -github.com/microsoft/go-winio v0.4.8/go.mod h1:kcIxxtKZE55DEncT/EOvFiygPobhUWpSDqDb47poQOU= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= @@ -149,12 +196,22 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420 h1:Yu3681ykYHDfLoI6XVjL4JWmkE+3TX9yfIWwRCh1kFM= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/image-spec v0.0.0-20170515205857-f03dbe35d449 h1:Aq8iG72akPb/kszE7ksZ5ldV+JYPYii/KZOxlpJF07s= @@ -164,9 +221,11 @@ github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c/go.mod h1:qT5X github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 h1:lNCW6THrCKBiJBpz8kbVGjC7MgdCGKwuvBgc7LoD6sw= github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8= @@ -189,22 +248,26 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE= github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E= -github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ= @@ -216,30 +279,58 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 h1:anGSYQpPhQwXlwsu5wmfq0nWkCNaMEMUwAv13Y92hd8= golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc h1:gkKoSkUmnU6bpS/VhkuO27bzQeSA51uaEfbOW5dNb68= +golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 h1:e6HwijUxhDe+hPNjZQQn9bA5PW3vNmnN64U2ZW759Lk= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181019160139-8e24a49d80f8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -247,20 +338,36 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c h1:jceGD5YNJGgGMkJz79agzOln1K9TaZUjv5ird16qniQ= -golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7 h1:ZUjXAXmrAyrmmCPHgCA/vChHcpsX27MZ3yBonD/z1KE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.22.1 h1:/7cs52RnTJmD43s3uxzlq2U7nqVTd/37viQwMrMNlOM= google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= @@ -268,19 +375,53 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= -gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.0.0-20191114100352-16d7abae0d2a h1:86XISgFlG7lPOWj6wYLxd+xqhhVt/WQjS4Tf39rP09s= +k8s.io/api v0.0.0-20191114100352-16d7abae0d2a/go.mod h1:qetVJgs5i8jwdFIdoOZ70ks0ecgU+dYwqZ2uD1srwOU= +k8s.io/api v0.17.2 h1:NF1UFXcKN7/OOv1uxdRz3qfra8AHsPav5M93hlV9+Dc= +k8s.io/api v0.17.2/go.mod h1:BS9fjjLc4CMuqfSO8vgbHPKMt5+SF0ET6u/RVDihTo4= +k8s.io/apimachinery v0.0.0-20191028221656-72ed19daf4bb h1:ZUNsbuPdXWrj0rZziRfCWcFg9ZP31OKkziqCbiphznI= +k8s.io/apimachinery v0.0.0-20191028221656-72ed19daf4bb/go.mod h1:llRdnznGEAqC3DcNm6yEj472xaFVfLM7hnYofMb12tQ= +k8s.io/apimachinery v0.17.2 h1:hwDQQFbdRlpnnsR64Asdi55GyCaIP/3WQpMmbNBeWr4= +k8s.io/apimachinery v0.17.2/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/client-go v0.0.0-20191114101535-6c5935290e33 h1:07mhG/2oEoo3N+sHVOo0L9PJ/qvbk3N5n2dj8IWefnQ= +k8s.io/client-go v0.0.0-20191114101535-6c5935290e33/go.mod h1:4L/zQOBkEf4pArQJ+CMk1/5xjA30B5oyWv+Bzb44DOw= +k8s.io/client-go v0.17.2 h1:ndIfkfXEGrNhLIgkr0+qhRguSD3u6DCmonepn1O6NYc= +k8s.io/client-go v0.17.2/go.mod h1:QAzRgsa0C2xl4/eVpeVAZMvikCn8Nm81yqVx3Kk9XYI= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.4.0 h1:lCJCxf/LIowc2IGS9TPjWDyXY4nOmdGdfcwwDQCOURQ= +k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/utils v0.0.0-20190801114015-581e00157fb1 h1:+ySTxfHnfzZb9ys375PXNlLhkJPLKgHajBU0N62BDvE= +k8s.io/utils v0.0.0-20190801114015-581e00157fb1/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo= +k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/api/http/client/client.go b/api/http/client/client.go index fb690105f..ba185950a 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -3,6 +3,7 @@ package client import ( "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "log" @@ -14,9 +15,10 @@ import ( "github.com/portainer/portainer/api" ) +var errInvalidResponseStatus = errors.New("Invalid response status (expecting 200)") + const ( - errInvalidResponseStatus = portainer.Error("Invalid response status (expecting 200)") - defaultHTTPTimeout = 5 + defaultHTTPTimeout = 5 ) // HTTPClient represents a client to send HTTP requests. @@ -56,7 +58,7 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain } if response.StatusCode != http.StatusOK { - return nil, portainer.ErrAzureInvalidCredentials + return nil, errors.New("Invalid Azure credentials") } var token AzureAuthenticationResponse diff --git a/api/http/errors/errors.go b/api/http/errors/errors.go new file mode 100644 index 000000000..2e6aeceb5 --- /dev/null +++ b/api/http/errors/errors.go @@ -0,0 +1,12 @@ +package errors + +import "errors" + +var ( + // ErrEndpointAccessDenied Access denied to endpoint error + ErrEndpointAccessDenied = errors.New("Access denied to endpoint") + // ErrUnauthorized Unauthorized error + ErrUnauthorized = errors.New("Unauthorized") + // ErrResourceAccessDenied Access denied to resource error + ErrResourceAccessDenied = errors.New("Access denied to resource") +) diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 8e51f65db..ad39e93df 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "log" "net/http" "strings" @@ -10,6 +11,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" ) type authenticatePayload struct { @@ -23,44 +26,40 @@ type authenticateResponse struct { func (payload *authenticatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Username) { - return portainer.Error("Invalid username") + return errors.New("Invalid username") } if govalidator.IsNull(payload.Password) { - return portainer.Error("Invalid password") + return errors.New("Invalid password") } return nil } func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - if handler.authDisabled { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} - } - var payload authenticatePayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } - u, err := handler.UserService.UserByUsername(payload.Username) - if err != nil && err != portainer.ErrObjectNotFound { + u, err := handler.DataStore.User().UserByUsername(payload.Username) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} } - if err == portainer.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) { - return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} + if err == bolterrors.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized} } if settings.AuthenticationMethod == portainer.AuthenticationLDAP { if u == nil && settings.LDAPSettings.AutoCreateUsers { return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings) } else if u == nil && !settings.LDAPSettings.AutoCreateUsers { - return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized} } return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings) } @@ -79,18 +78,13 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer. log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) } - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - return handler.writeToken(w, user) } func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError { err := handler.CryptoService.CompareHashAndData(user.Password, password) if err != nil { - return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized} } return handler.writeToken(w, user) @@ -103,12 +97,11 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use } user := &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, - PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), + Username: username, + Role: portainer.StandardUserRole, } - err = handler.UserService.CreateUser(user) + err = handler.DataStore.User().CreateUser(user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } @@ -118,11 +111,6 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) } - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - return handler.writeToken(w, user) } @@ -146,7 +134,7 @@ func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *p } func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error { - teams, err := handler.TeamService.Teams() + teams, err := handler.DataStore.Team().Teams() if err != nil { return err } @@ -156,7 +144,7 @@ func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portain return err } - userMemberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(user.ID) + userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { return err } @@ -174,7 +162,7 @@ func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portain Role: portainer.TeamMember, } - err := handler.TeamMembershipService.CreateTeamMembership(membership) + err := handler.DataStore.TeamMembership().CreateTeamMembership(membership) if err != nil { return err } diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index de0cbd8dc..b090cb98f 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -1,8 +1,7 @@ package auth import ( - "encoding/json" - "io/ioutil" + "errors" "log" "net/http" @@ -10,6 +9,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" ) type oauthPayload struct { @@ -18,57 +19,26 @@ type oauthPayload struct { func (payload *oauthPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Code) { - return portainer.Error("Invalid OAuth authorization code") + return errors.New("Invalid OAuth authorization code") } return nil } -func (handler *Handler) authenticateThroughExtension(code, licenseKey string, settings *portainer.OAuthSettings) (string, error) { - extensionURL := handler.ProxyManager.GetExtensionURL(portainer.OAuthAuthenticationExtension) - - encodedConfiguration, err := json.Marshal(settings) - if err != nil { - return "", nil +func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) { + if code == "" { + return "", errors.New("Invalid OAuth authorization code") } - req, err := http.NewRequest("GET", extensionURL+"/validate", nil) + if settings == nil { + return "", errors.New("Invalid OAuth configuration") + } + + username, err := handler.OAuthService.Authenticate(code, settings) if err != nil { return "", err } - client := &http.Client{} - req.Header.Set("X-OAuth-Config", string(encodedConfiguration)) - req.Header.Set("X-OAuth-Code", code) - req.Header.Set("X-PortainerExtension-License", licenseKey) - - resp, err := client.Do(req) - if err != nil { - return "", err - } - - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - - type extensionResponse struct { - Username string `json:"Username,omitempty"` - Err string `json:"err,omitempty"` - Details string `json:"details,omitempty"` - } - - var extResp extensionResponse - err = json.Unmarshal(body, &extResp) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", portainer.Error(extResp.Err + ":" + extResp.Details) - } - - return extResp.Username, nil + return username, nil } func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -78,45 +48,37 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } if settings.AuthenticationMethod != 3 { - return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", portainer.Error("OAuth authentication is not enabled")} + return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")} } - extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings) + username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings) if err != nil { log.Printf("[DEBUG] - OAuth authentication error: %s", err) - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized} } - user, err := handler.UserService.UserByUsername(username) - if err != nil && err != portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().UserByUsername(username) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} } if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { - return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", httperrors.ErrUnauthorized} } if user == nil { user = &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, - PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), + Username: username, + Role: portainer.StandardUserRole, } - err = handler.UserService.CreateUser(user) + err = handler.DataStore.User().CreateUser(user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } @@ -128,16 +90,12 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h Role: portainer.TeamMember, } - err = handler.TeamMembershipService.CreateTeamMembership(membership) + err = handler.DataStore.TeamMembership().CreateTeamMembership(membership) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err} } } - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } } return handler.writeToken(w, user) diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 24a211f94..5ad73712a 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -7,47 +7,34 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" ) -const ( - // ErrInvalidCredentials is an error raised when credentials for a user are invalid - ErrInvalidCredentials = portainer.Error("Invalid credentials") - // ErrAuthDisabled is an error raised when trying to access the authentication endpoints - // when the server has been started with the --no-auth flag - ErrAuthDisabled = portainer.Error("Authentication is disabled") -) - // Handler is the HTTP handler used to handle authentication operations. type Handler struct { *mux.Router - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SettingsService portainer.SettingsService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ExtensionService portainer.ExtensionService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - RoleService portainer.RoleService - ProxyManager *proxy.Manager - AuthorizationService *portainer.AuthorizationService + DataStore portainer.DataStore + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + OAuthService portainer.OAuthService + ProxyManager *proxy.Manager + KubernetesTokenCacheManager *kubernetes.TokenCacheManager } // NewHandler creates a handler to manage authentication operations. -func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *Handler { +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler { h := &Handler{ - Router: mux.NewRouter(), - authDisabled: authDisabled, + Router: mux.NewRouter(), } h.Handle("/auth/oauth/validate", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost) h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + h.Handle("/auth/logout", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go new file mode 100644 index 000000000..90519d5b9 --- /dev/null +++ b/api/http/handler/auth/logout.go @@ -0,0 +1,21 @@ +package auth + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/http/security" +) + +// POST request on /logout +func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err} + } + + handler.KubernetesTokenCacheManager.RemoveUserFromCache(int(tokenData.ID)) + + return response.Empty(w) +} diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go new file mode 100644 index 000000000..af1a81e48 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -0,0 +1,290 @@ +package customtemplates + +import ( + "errors" + "net/http" + "strconv" + + "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" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func (handler *Handler) customTemplateCreate(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} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user details from authentication token", err} + } + + customTemplate, err := handler.createCustomTemplate(method, r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create custom template", err} + } + + customTemplate.CreatedByUserID = tokenData.ID + + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} + } + + for _, existingTemplate := range customTemplates { + if existingTemplate.Title == customTemplate.Title { + return &httperror.HandlerError{http.StatusInternalServerError, "Template name must be unique", errors.New("Template name must be unique")} + } + } + + err = handler.DataStore.CustomTemplate().CreateCustomTemplate(customTemplate) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create custom template", err} + } + + resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, tokenData.ID) + + err = handler.DataStore.ResourceControl().CreateResourceControl(resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err} + } + + customTemplate.ResourceControl = resourceControl + + return response.JSON(w, customTemplate) +} + +func (handler *Handler) createCustomTemplate(method string, r *http.Request) (*portainer.CustomTemplate, error) { + switch method { + case "string": + return handler.createCustomTemplateFromFileContent(r) + case "repository": + return handler.createCustomTemplateFromGitRepository(r) + case "file": + return handler.createCustomTemplateFromFileUpload(r) + } + return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file") +} + +type customTemplateFromFileContentPayload struct { + Logo string + Title string + FileContent string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType +} + +func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return errors.New("Invalid custom template title") + } + if govalidator.IsNull(payload.Description) { + return errors.New("Invalid custom template description") + } + if govalidator.IsNull(payload.FileContent) { + return errors.New("Invalid file content") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return errors.New("Invalid custom template type") + } + return nil +} + +func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + EntryPoint: filesystem.ComposeFileDefaultName, + Description: payload.Description, + Note: payload.Note, + Platform: (payload.Platform), + Type: (payload.Type), + Logo: payload.Logo, + } + + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return nil, err + } + customTemplate.ProjectPath = projectPath + + return customTemplate, nil +} + +type customTemplateFromGitRepositoryPayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + RepositoryURL string + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return errors.New("Invalid custom template title") + } + if govalidator.IsNull(payload.Description) { + return errors.New("Invalid custom template description") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return errors.New("Invalid custom template type") + } + return nil +} + +func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + EntryPoint: payload.ComposeFilePathInRepository, + Description: payload.Description, + Note: payload.Note, + Platform: payload.Platform, + Type: payload.Type, + Logo: payload.Logo, + } + + projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID)) + customTemplate.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 + } + + return customTemplate, nil +} + +type customTemplateFromFileUploadPayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + FileContent []byte +} + +func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error { + title, err := request.RetrieveMultiPartFormValue(r, "Title", false) + if err != nil { + return errors.New("Invalid custom template title") + } + payload.Title = title + + description, err := request.RetrieveMultiPartFormValue(r, "Description", false) + if err != nil { + return errors.New("Invalid custom template description") + } + + payload.Description = description + + note, _ := request.RetrieveMultiPartFormValue(r, "Note", true) + payload.Note = note + + platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) + templatePlatform := portainer.CustomTemplatePlatform(platform) + if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + payload.Platform = templatePlatform + + typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true) + templateType := portainer.StackType(typeNumeral) + if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack { + return errors.New("Invalid custom template type") + } + payload.Type = templateType + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.FileContent = composeFileContent + + return nil +} + +func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) { + payload := &customTemplateFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return nil, err + } + + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() + customTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + Description: payload.Description, + Note: payload.Note, + Platform: payload.Platform, + Type: payload.Type, + Logo: payload.Logo, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return nil, err + } + customTemplate.ProjectPath = projectPath + + return customTemplate, nil +} diff --git a/api/http/handler/customtemplates/customtemplate_delete.go b/api/http/handler/customtemplates/customtemplate_delete.go new file mode 100644 index 000000000..24500894a --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_delete.go @@ -0,0 +1,63 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template 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} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the custom template", err} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + err = handler.DataStore.CustomTemplate().DeleteCustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the custom template from the database", err} + } + + err = handler.FileService.RemoveDirectory(customTemplate.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove custom template files from disk", err} + } + + if resourceControl != nil { + err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} + } + } + + return response.Empty(w) + +} diff --git a/api/http/handler/customtemplates/customtemplate_file.go b/api/http/handler/customtemplates/customtemplate_file.go new file mode 100644 index 000000000..5fa74d813 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_file.go @@ -0,0 +1,38 @@ +package customtemplates + +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" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type fileResponse struct { + FileContent string +} + +// GET request on /api/custom_templates/:id/file +func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid custom template identifier route variable", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + fileContent, err := handler.FileService.GetFileContent(path.Join(customTemplate.ProjectPath, customTemplate.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom template file from disk", err} + } + + return response.JSON(w, &fileResponse{FileContent: string(fileContent)}) +} diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go new file mode 100644 index 000000000..6259e3eb7 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_inspect.go @@ -0,0 +1,49 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template identifier route variable", err} + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the custom template", err} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + if resourceControl != nil { + customTemplate.ResourceControl = resourceControl + } + + return response.JSON(w, customTemplate) +} diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go new file mode 100644 index 000000000..b33cb8297 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -0,0 +1,67 @@ +package customtemplates + +import ( + "net/http" + + 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/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} + } + + stackType, _ := request.RetrieveNumericQueryParameter(r, "type", true) + + resourceControls, err := handler.DataStore.ResourceControl().ResourceControls() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + + customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) + + customTemplates = filterTemplatesByEngineType(customTemplates, portainer.StackType(stackType)) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin { + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err} + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) + } + + return response.JSON(w, customTemplates) +} + +func filterTemplatesByEngineType(templates []portainer.CustomTemplate, stackType portainer.StackType) []portainer.CustomTemplate { + if stackType == 0 { + return templates + } + + filteredTemplates := []portainer.CustomTemplate{} + + for _, template := range templates { + if template.Type == stackType { + filteredTemplates = append(filteredTemplates, template) + } + } + + return filteredTemplates +} diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go new file mode 100644 index 000000000..fd10d6e23 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -0,0 +1,107 @@ +package customtemplates + +import ( + "errors" + "net/http" + "strconv" + + bolterrors "github.com/portainer/portainer/api/bolt/errors" + + "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" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" +) + +type customTemplateUpdatePayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + Type portainer.StackType + FileContent string +} + +func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Title) { + return errors.New("Invalid custom template title") + } + if govalidator.IsNull(payload.FileContent) { + return errors.New("Invalid file content") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack { + return errors.New("Invalid custom template type") + } + if govalidator.IsNull(payload.Description) { + return errors.New("Invalid custom template description") + } + return nil +} + +func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template identifier route variable", err} + } + + var payload customTemplateUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} + } + + for _, existingTemplate := range customTemplates { + if existingTemplate.ID != portainer.CustomTemplateID(customTemplateID) && existingTemplate.Title == payload.Title { + return &httperror.HandlerError{http.StatusInternalServerError, "Template name must be unique", errors.New("Template name must be unique")} + } + } + + customTemplate, err := handler.DataStore.CustomTemplate().CustomTemplate(portainer.CustomTemplateID(customTemplateID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a custom template with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a custom template with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + templateFolder := strconv.Itoa(customTemplateID) + _, err = handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated custom template file on disk", err} + } + + customTemplate.Title = payload.Title + customTemplate.Logo = payload.Logo + customTemplate.Description = payload.Description + customTemplate.Note = payload.Note + customTemplate.Platform = payload.Platform + customTemplate.Type = payload.Type + + err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist custom template changes inside the database", err} + } + + return response.JSON(w, customTemplate) +} diff --git a/api/http/handler/customtemplates/git.go b/api/http/handler/customtemplates/git.go new file mode 100644 index 000000000..b4f3e3211 --- /dev/null +++ b/api/http/handler/customtemplates/git.go @@ -0,0 +1,17 @@ +package customtemplates + +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/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go new file mode 100644 index 000000000..ff8fce4a5 --- /dev/null +++ b/api/http/handler/customtemplates/handler.go @@ -0,0 +1,60 @@ +package customtemplates + +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" + "github.com/portainer/portainer/api/internal/authorization" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + DataStore portainer.DataStore + 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(), + } + h.Handle("/custom_templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost) + h.Handle("/custom_templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateInspect))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}/file", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateFile))).Methods(http.MethodGet) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateUpdate))).Methods(http.MethodPut) + h.Handle("/custom_templates/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateDelete))).Methods(http.MethodDelete) + return h +} + +func userCanEditTemplate(customTemplate *portainer.CustomTemplate, securityContext *security.RestrictedRequestContext) bool { + return securityContext.IsAdmin || customTemplate.CreatedByUserID == securityContext.UserID +} + +func userCanAccessTemplate(customTemplate portainer.CustomTemplate, securityContext *security.RestrictedRequestContext, resourceControl *portainer.ResourceControl) bool { + if securityContext.IsAdmin || customTemplate.CreatedByUserID == securityContext.UserID { + return true + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range securityContext.UserMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + + if resourceControl != nil && authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { + return true + } + + return false +} diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go index b149a2a35..fd4713849 100644 --- a/api/http/handler/dockerhub/dockerhub_inspect.go +++ b/api/http/handler/dockerhub/dockerhub_inspect.go @@ -9,7 +9,7 @@ import ( // GET request on /api/dockerhub func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - dockerhub, err := handler.DockerHubService.DockerHub() + dockerhub, err := handler.DataStore.DockerHub().DockerHub() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} } diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go index 5606677fb..e12d3ad24 100644 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -1,6 +1,7 @@ package dockerhub import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -18,7 +19,7 @@ type dockerhubUpdatePayload struct { func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error { if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") } return nil } @@ -43,7 +44,7 @@ func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) dockerhub.Password = payload.Password } - err = handler.DockerHubService.UpdateDockerHub(dockerhub) + err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} } diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go index ba4ed2c34..f1328acb8 100644 --- a/api/http/handler/dockerhub/handler.go +++ b/api/http/handler/dockerhub/handler.go @@ -16,7 +16,7 @@ func hideFields(dockerHub *portainer.DockerHub) { // Handler is the HTTP handler used to handle DockerHub operations. type Handler struct { *mux.Router - DockerHubService portainer.DockerHubService + DataStore portainer.DataStore } // NewHandler creates a handler to manage Dockerhub operations. diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go index 8ff2f7693..b34711eda 100644 --- a/api/http/handler/edgegroups/associated_endpoints.go +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -11,7 +11,7 @@ func (handler *Handler) getEndpointsByTags(tagIDs []portainer.TagID, partialMatc return []portainer.EndpointID{}, nil } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return nil, err } @@ -20,7 +20,7 @@ func (handler *Handler) getEndpointsByTags(tagIDs []portainer.TagID, partialMatc tags := []portainer.Tag{} for _, tagID := range tagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().Tag(tagID) if err != nil { return nil, err } @@ -38,7 +38,7 @@ func (handler *Handler) getEndpointsByTags(tagIDs []portainer.TagID, partialMatc results := []portainer.EndpointID{} for _, endpoint := range endpoints { - if _, ok := endpointSet[endpoint.ID]; ok && endpoint.Type == portainer.EdgeAgentEnvironment { + if _, ok := endpointSet[endpoint.ID]; ok && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { results = append(results, endpoint.ID) } } diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 26bbd0d90..3e767891e 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -1,6 +1,7 @@ package edgegroups import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -20,13 +21,13 @@ type edgeGroupCreatePayload struct { func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid Edge group name") + return errors.New("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") + return errors.New("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 errors.New("Endpoints is mandatory for a static Edge group") } return nil } @@ -38,14 +39,14 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().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")} + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", errors.New("Edge group name must be unique")} } } @@ -62,19 +63,19 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) } else { endpointIDs := []portainer.EndpointID{} for _, endpointID := range payload.Endpoints { - endpoint, err := handler.EndpointService.Endpoint(endpointID) + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} } - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { endpointIDs = append(endpointIDs, endpoint.ID) } } edgeGroup.Endpoints = endpointIDs } - err = handler.EdgeGroupService.CreateEdgeGroup(edgeGroup) + err = handler.DataStore.EdgeGroup().CreateEdgeGroup(edgeGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Edge group inside the database", err} } diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go index 8ad9e949f..a45486bfc 100644 --- a/api/http/handler/edgegroups/edgegroup_delete.go +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -1,12 +1,14 @@ package edgegroups import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -15,14 +17,14 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} } - _, err = handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) - if err == portainer.ErrObjectNotFound { + _, err = handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == bolterrors.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() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} } @@ -30,12 +32,12 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) 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")} + return &httperror.HandlerError{http.StatusForbidden, "Edge group is used by an Edge stack", errors.New("Edge group is used by an Edge stack")} } } } - err = handler.EdgeGroupService.DeleteEdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + err = handler.DataStore.EdgeGroup().DeleteEdgeGroup(portainer.EdgeGroupID(edgeGroupID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge group from the database", err} } diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index 5fdadf2ec..9f7f31173 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -15,8 +16,8 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge group identifier route variable", err} } - edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) - if err == portainer.ErrObjectNotFound { + edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == errors.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} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index f859300aa..7ba9fbdcf 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -14,12 +14,12 @@ type decoratedEdgeGroup struct { } func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge groups from the database", err} } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge stacks from the database", err} } diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index d6a72ea6d..e71f48457 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -1,13 +1,16 @@ package edgegroups import ( + "errors" "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" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/edge" ) type edgeGroupUpdatePayload struct { @@ -20,13 +23,13 @@ type edgeGroupUpdatePayload struct { func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid Edge group name") + return errors.New("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") + return errors.New("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 errors.New("Endpoints is mandatory for a static Edge group") } return nil } @@ -43,37 +46,37 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - edgeGroup, err := handler.EdgeGroupService.EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) - if err == portainer.ErrObjectNotFound { + edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if err == bolterrors.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() + edgeGroups, err := handler.DataStore.EdgeGroup().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")} + return &httperror.HandlerError{http.StatusBadRequest, "Edge group name must be unique", errors.New("Edge group name must be unique")} } } edgeGroup.Name = payload.Name } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} } - oldRelatedEndpoints := portainer.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) edgeGroup.Dynamic = payload.Dynamic if edgeGroup.Dynamic { @@ -81,12 +84,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) } else { endpointIDs := []portainer.EndpointID{} for _, endpointID := range payload.Endpoints { - endpoint, err := handler.EndpointService.Endpoint(endpointID) + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} } - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { endpointIDs = append(endpointIDs, endpoint.ID) } } @@ -97,12 +100,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) edgeGroup.PartialMatch = *payload.PartialMatch } - err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + err = handler.DataStore.EdgeGroup().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) + newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) for _, endpointID := range endpointsToUpdate { @@ -116,39 +119,39 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) } func (handler *Handler) updateEndpoint(endpointID portainer.EndpointID) error { - relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) if err != nil { return err } - endpoint, err := handler.EndpointService.Endpoint(endpointID) + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { return err } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { return err } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return err } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return err } edgeStackSet := map[portainer.EdgeStackID]bool{} - endpointEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + endpointEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) for _, edgeStackID := range endpointEdgeStacks { edgeStackSet[edgeStackID] = true } relation.EdgeStacks = edgeStackSet - return handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) + return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) } diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go index 874b13477..374ca4ab2 100644 --- a/api/http/handler/edgegroups/handler.go +++ b/api/http/handler/edgegroups/handler.go @@ -12,12 +12,7 @@ import ( // 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 + DataStore portainer.DataStore } // NewHandler creates a handler to manage endpoint group operations. @@ -26,14 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), } h.Handle("/edge_groups", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupCreate)))).Methods(http.MethodPost) h.Handle("/edge_groups", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupList))).Methods(http.MethodGet) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupList)))).Methods(http.MethodGet) h.Handle("/edge_groups/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupInspect)))).Methods(http.MethodGet) h.Handle("/edge_groups/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupUpdate)))).Methods(http.MethodPut) h.Handle("/edge_groups/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete) return h } diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go new file mode 100644 index 000000000..e329316a7 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -0,0 +1,220 @@ +package edgejobs + +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" +) + +// POST /api/edge_jobs?method=file|string +func (handler *Handler) edgeJobCreate(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. Valid values are: file or string", err} + } + + switch method { + case "string": + return handler.createEdgeJobFromFileContent(w, r) + case "file": + return handler.createEdgeJobFromFile(w, r) + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)} + } +} + +type edgeJobCreateFromFileContentPayload struct { + Name string + CronExpression string + Recurring bool + Endpoints []portainer.EndpointID + FileContent string +} + +func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return errors.New("Invalid Edge job name") + } + + if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) { + return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") + } + + if govalidator.IsNull(payload.CronExpression) { + return errors.New("Invalid cron expression") + } + + if payload.Endpoints == nil || len(payload.Endpoints) == 0 { + return errors.New("Invalid endpoints payload") + } + + if govalidator.IsNull(payload.FileContent) { + return errors.New("Invalid script file content") + } + + return nil +} + +func (handler *Handler) createEdgeJobFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload edgeJobCreateFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeJob := handler.createEdgeJobObjectFromFileContentPayload(&payload) + + err = handler.addAndPersistEdgeJob(edgeJob, []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err} + } + + return response.JSON(w, edgeJob) +} + +type edgeJobCreateFromFilePayload struct { + Name string + CronExpression string + Recurring bool + Endpoints []portainer.EndpointID + File []byte +} + +func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return errors.New("Invalid Edge job name") + } + + if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") + } + payload.Name = name + + cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false) + if err != nil { + return errors.New("Invalid cron expression") + } + payload.CronExpression = cronExpression + + var endpoints []portainer.EndpointID + err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false) + if err != nil { + return errors.New("Invalid endpoints") + } + payload.Endpoints = endpoints + + file, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return errors.New("Invalid script file. Ensure that the file is uploaded correctly") + } + payload.File = file + + return nil +} + +func (handler *Handler) createEdgeJobFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := &edgeJobCreateFromFilePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeJob := handler.createEdgeJobObjectFromFilePayload(payload) + + err = handler.addAndPersistEdgeJob(edgeJob, payload.File) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err} + } + + return response.JSON(w, edgeJob) +} + +func (handler *Handler) createEdgeJobObjectFromFilePayload(payload *edgeJobCreateFromFilePayload) *portainer.EdgeJob { + edgeJobIdentifier := portainer.EdgeJobID(handler.DataStore.EdgeJob().GetNextIdentifier()) + + endpoints := convertEndpointsToMetaObject(payload.Endpoints) + + edgeJob := &portainer.EdgeJob{ + ID: edgeJobIdentifier, + Name: payload.Name, + CronExpression: payload.CronExpression, + Recurring: payload.Recurring, + Created: time.Now().Unix(), + Endpoints: endpoints, + Version: 1, + } + + return edgeJob +} + +func (handler *Handler) createEdgeJobObjectFromFileContentPayload(payload *edgeJobCreateFromFileContentPayload) *portainer.EdgeJob { + edgeJobIdentifier := portainer.EdgeJobID(handler.DataStore.EdgeJob().GetNextIdentifier()) + + endpoints := convertEndpointsToMetaObject(payload.Endpoints) + + edgeJob := &portainer.EdgeJob{ + ID: edgeJobIdentifier, + Name: payload.Name, + CronExpression: payload.CronExpression, + Recurring: payload.Recurring, + Created: time.Now().Unix(), + Endpoints: endpoints, + Version: 1, + } + + return edgeJob +} + +func (handler *Handler) addAndPersistEdgeJob(edgeJob *portainer.EdgeJob, file []byte) error { + edgeCronExpression := strings.Split(edgeJob.CronExpression, " ") + if len(edgeCronExpression) == 6 { + edgeCronExpression = edgeCronExpression[1:] + } + edgeJob.CronExpression = strings.Join(edgeCronExpression, " ") + + for ID := range edgeJob.Endpoints { + endpoint, err := handler.DataStore.Endpoint().Endpoint(ID) + if err != nil { + return err + } + + if endpoint.Type != portainer.EdgeAgentOnDockerEnvironment && endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + delete(edgeJob.Endpoints, ID) + } + } + + if len(edgeJob.Endpoints) == 0 { + return errors.New("Endpoints are mandatory for an Edge job") + } + + scriptPath, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), file) + if err != nil { + return err + } + edgeJob.ScriptPath = scriptPath + + for endpointID := range edgeJob.Endpoints { + handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob) + } + + return handler.DataStore.EdgeJob().CreateEdgeJob(edgeJob) +} + +func convertEndpointsToMetaObject(endpoints []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeJobEndpointMeta { + endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} + + for _, endpointID := range endpoints { + endpointsMap[endpointID] = portainer.EdgeJobEndpointMeta{} + } + + return endpointsMap +} diff --git a/api/http/handler/edgejobs/edgejob_delete.go b/api/http/handler/edgejobs/edgejob_delete.go new file mode 100644 index 000000000..66efdd55e --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_delete.go @@ -0,0 +1,41 @@ +package edgejobs + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + edgeJobFolder := handler.FileService.GetEdgeJobFolder(strconv.Itoa(edgeJobID)) + err = handler.FileService.RemoveDirectory(edgeJobFolder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the Edge job on the filesystem", err} + } + + handler.ReverseTunnelService.RemoveEdgeJob(edgeJob.ID) + + err = handler.DataStore.EdgeJob().DeleteEdgeJob(edgeJob.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge job from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgejobs/edgejob_file.go b/api/http/handler/edgejobs/edgejob_file.go new file mode 100644 index 000000000..25e71366a --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_file.go @@ -0,0 +1,37 @@ +package edgejobs + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type edgeJobFileResponse struct { + FileContent string `json:"FileContent"` +} + +// GET request on /api/edge_jobs/:id/file +func (handler *Handler) edgeJobFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + edgeJobFileContent, err := handler.FileService.GetFileContent(edgeJob.ScriptPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge job script file from disk", err} + } + + return response.JSON(w, &edgeJobFileResponse{FileContent: string(edgeJobFileContent)}) +} diff --git a/api/http/handler/edgejobs/edgejob_inspect.go b/api/http/handler/edgejobs/edgejob_inspect.go new file mode 100644 index 000000000..0625987cf --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_inspect.go @@ -0,0 +1,43 @@ +package edgejobs + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type edgeJobInspectResponse struct { + *portainer.EdgeJob + Endpoints []portainer.EndpointID +} + +func (handler *Handler) edgeJobInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + endpointIDs := []portainer.EndpointID{} + + for endpointID := range edgeJob.Endpoints { + endpointIDs = append(endpointIDs, endpointID) + } + + responseObj := edgeJobInspectResponse{ + EdgeJob: edgeJob, + Endpoints: endpointIDs, + } + + return response.JSON(w, responseObj) +} diff --git a/api/http/handler/edgejobs/edgejob_list.go b/api/http/handler/edgejobs/edgejob_list.go new file mode 100644 index 000000000..c95d0946b --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_list.go @@ -0,0 +1,18 @@ +package edgejobs + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +// GET request on /api/edge_jobs +func (handler *Handler) edgeJobList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge jobs from the database", err} + } + + return response.JSON(w, edgeJobs) +} diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go new file mode 100644 index 000000000..e49f3c978 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go @@ -0,0 +1,53 @@ +package edgejobs + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// DELETE request on /api/edge_jobs/:id/tasks/:taskID/logs +func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + endpointID := portainer.EndpointID(taskID) + + meta := edgeJob.Endpoints[endpointID] + meta.CollectLogs = false + meta.LogsStatus = portainer.EdgeJobLogsStatusIdle + edgeJob.Endpoints[endpointID] = meta + + err = handler.FileService.ClearEdgeJobTaskLogs(strconv.Itoa(edgeJobID), strconv.Itoa(taskID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clear log file from disk", err} + } + + handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob) + + err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go new file mode 100644 index 000000000..cb09aa2db --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go @@ -0,0 +1,47 @@ +package edgejobs + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// POST request on /api/edge_jobs/:id/tasks/:taskID/logs +func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + endpointID := portainer.EndpointID(taskID) + + meta := edgeJob.Endpoints[endpointID] + meta.CollectLogs = true + meta.LogsStatus = portainer.EdgeJobLogsStatusPending + edgeJob.Endpoints[endpointID] = meta + + handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob) + + err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go new file mode 100644 index 000000000..5e63efa1b --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go @@ -0,0 +1,36 @@ +package edgejobs + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +type fileResponse struct { + FileContent string `json:"FileContent"` +} + +// GET request on /api/edge_jobs/:id/tasks/:taskID/logs +func (handler *Handler) edgeJobTaskLogsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err} + } + + logFileContent, err := handler.FileService.GetEdgeJobTaskLogFileContent(strconv.Itoa(edgeJobID), strconv.Itoa(taskID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve log file from disk", err} + } + + return response.JSON(w, &fileResponse{FileContent: string(logFileContent)}) +} + +// fmt.Sprintf("/tmp/edge_jobs/%s/logs_%s", edgeJobID, taskID) diff --git a/api/http/handler/edgejobs/edgejob_tasks_list.go b/api/http/handler/edgejobs/edgejob_tasks_list.go new file mode 100644 index 000000000..6b021255c --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasks_list.go @@ -0,0 +1,48 @@ +package edgejobs + +import ( + "fmt" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type taskContainer struct { + ID string `json:"Id"` + EndpointID portainer.EndpointID `json:"EndpointId"` + LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"` +} + +// GET request on /api/edge_jobs/:id/tasks +func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + tasks := make([]taskContainer, 0) + + for endpointID, meta := range edgeJob.Endpoints { + + cronTask := taskContainer{ + ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID), + EndpointID: endpointID, + LogsStatus: meta.LogsStatus, + } + + tasks = append(tasks, cronTask) + } + + return response.JSON(w, tasks) +} diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go new file mode 100644 index 000000000..26d756320 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -0,0 +1,120 @@ +package edgejobs + +import ( + "errors" + "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" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type edgeJobUpdatePayload struct { + Name *string + CronExpression *string + Recurring *bool + Endpoints []portainer.EndpointID + FileContent *string +} + +func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { + if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") + } + return nil +} + +func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err} + } + + var payload edgeJobUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err} + } + + err = handler.updateEdgeSchedule(edgeJob, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge job", err} + } + + err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes inside the database", err} + } + + return response.JSON(w, edgeJob) +} + +func (handler *Handler) updateEdgeSchedule(edgeJob *portainer.EdgeJob, payload *edgeJobUpdatePayload) error { + if payload.Name != nil { + edgeJob.Name = *payload.Name + } + + if payload.Endpoints != nil { + endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} + + for _, endpointID := range payload.Endpoints { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return err + } + + if endpoint.Type != portainer.EdgeAgentOnDockerEnvironment && endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + continue + } + + if meta, ok := edgeJob.Endpoints[endpointID]; ok { + endpointsMap[endpointID] = meta + } else { + endpointsMap[endpointID] = portainer.EdgeJobEndpointMeta{} + } + } + + edgeJob.Endpoints = endpointsMap + } + + updateVersion := false + if payload.CronExpression != nil { + edgeJob.CronExpression = *payload.CronExpression + updateVersion = true + } + + if payload.FileContent != nil { + _, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), []byte(*payload.FileContent)) + if err != nil { + return err + } + + updateVersion = true + } + + if payload.Recurring != nil { + edgeJob.Recurring = *payload.Recurring + updateVersion = true + } + + if updateVersion { + edgeJob.Version++ + } + + for endpointID := range edgeJob.Endpoints { + handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob) + } + + return nil +} diff --git a/api/http/handler/edgejobs/handler.go b/api/http/handler/edgejobs/handler.go new file mode 100644 index 000000000..35800b6e3 --- /dev/null +++ b/api/http/handler/edgejobs/handler.go @@ -0,0 +1,47 @@ +package edgejobs + +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 Edge job operations. +type Handler struct { + *mux.Router + DataStore portainer.DataStore + FileService portainer.FileService + ReverseTunnelService portainer.ReverseTunnelService +} + +// NewHandler creates a handler to manage Edge job operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/edge_jobs", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet) + h.Handle("/edge_jobs", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost) + h.Handle("/edge_jobs/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobInspect)))).Methods(http.MethodGet) + h.Handle("/edge_jobs/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobUpdate)))).Methods(http.MethodPut) + h.Handle("/edge_jobs/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobDelete)))).Methods(http.MethodDelete) + h.Handle("/edge_jobs/{id}/file", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobFile)))).Methods(http.MethodGet) + h.Handle("/edge_jobs/{id}/tasks", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksList)))).Methods(http.MethodGet) + h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTaskLogsInspect)))).Methods(http.MethodGet) + h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksCollect)))).Methods(http.MethodPost) + h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksClear)))).Methods(http.MethodDelete) + return h +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index e3a315cb9..0f3224e95 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/internal/edge" ) // POST request on /api/endpoint_groups @@ -27,32 +28,32 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err} } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().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) + relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) for _, endpointID := range relatedEndpoints { - relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + relation, err := handler.DataStore.EndpointRelation().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) + err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} } @@ -81,13 +82,13 @@ type swarmStackFromFileContentPayload struct { func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { - return portainer.Error("Edge Groups are mandatory for an Edge stack") + return errors.New("Edge Groups are mandatory for an Edge stack") } return nil } @@ -104,7 +105,7 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta return nil, err } - stackID := handler.EdgeStackService.GetNextIdentifier() + stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ ID: portainer.EdgeStackID(stackID), Name: payload.Name, @@ -122,7 +123,7 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta } stack.ProjectPath = projectPath - err = handler.EdgeStackService.CreateEdgeStack(stack) + err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { return nil, err } @@ -143,19 +144,19 @@ type swarmStackFromGitRepositoryPayload struct { func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("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") + return errors.New("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") + return errors.New("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 errors.New("Edge Groups are mandatory for an Edge stack") } return nil } @@ -172,7 +173,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por return nil, err } - stackID := handler.EdgeStackService.GetNextIdentifier() + stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ ID: portainer.EdgeStackID(stackID), Name: payload.Name, @@ -200,7 +201,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por return nil, err } - err = handler.EdgeStackService.CreateEdgeStack(stack) + err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { return nil, err } @@ -217,20 +218,20 @@ type swarmStackFromFileUploadPayload struct { func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return portainer.Error("Invalid stack name") + return errors.New("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") + return errors.New("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") + return errors.New("Edge Groups are mandatory for an Edge stack") } payload.EdgeGroups = edgeGroups return nil @@ -248,7 +249,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai return nil, err } - stackID := handler.EdgeStackService.GetNextIdentifier() + stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ ID: portainer.EdgeStackID(stackID), Name: payload.Name, @@ -266,7 +267,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai } stack.ProjectPath = projectPath - err = handler.EdgeStackService.CreateEdgeStack(stack) + err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { return nil, err } @@ -275,14 +276,14 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai } func (handler *Handler) validateUniqueName(name string) error { - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().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 errors.New("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 index ae5d1b476..ee01443f5 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/edge" ) func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -15,44 +17,44 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} } - edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) - if err == portainer.ErrObjectNotFound { + edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == errors.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)) + err = handler.DataStore.EdgeStack().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() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().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) + relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) for _, endpointID := range relatedEndpoints { - relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + relation, err := handler.DataStore.EndpointRelation().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) + err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} } diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go index c82348b8d..d45dc433c 100644 --- a/api/http/handler/edgestacks/edgestack_file.go +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) type stackFileResponse struct { @@ -21,8 +22,8 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} } - stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) + if err == errors.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} diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go index 66a591633..50a960817 100644 --- a/api/http/handler/edgestacks/edgestack_inspect.go +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -15,8 +16,8 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} } - edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) - if err == portainer.ErrObjectNotFound { + edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == errors.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} diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index dd15e58c1..1db0159c6 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -8,7 +8,7 @@ import ( ) func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} } diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index bcf0a639b..f1d870d51 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -1,6 +1,7 @@ package edgestacks import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -8,6 +9,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type updateStatusPayload struct { @@ -18,13 +20,13 @@ type updateStatusPayload struct { func (payload *updateStatusPayload) Validate(r *http.Request) error { if payload.Status == nil { - return portainer.Error("Invalid status") + return errors.New("Invalid status") } if payload.EndpointID == nil { - return portainer.Error("Invalid EndpointID") + return errors.New("Invalid EndpointID") } if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) { - return portainer.Error("Error message is mandatory when status is error") + return errors.New("Error message is mandatory when status is error") } return nil } @@ -35,8 +37,8 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) + if err == bolterrors.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} @@ -48,8 +50,8 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(*payload.EndpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(*payload.EndpointID)) + if err == bolterrors.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} @@ -66,7 +68,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req EndpointID: *payload.EndpointID, } - err = handler.EdgeStackService.UpdateEdgeStack(stack.ID, stack) + err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index 404ff1d92..ccc65bf44 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -1,6 +1,7 @@ package edgestacks import ( + "errors" "net/http" "strconv" @@ -9,6 +10,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/edge" ) type updateEdgeStackPayload struct { @@ -20,10 +23,10 @@ type updateEdgeStackPayload struct { func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 { - return portainer.Error("Edge Groups are mandatory for an Edge stack") + return errors.New("Edge Groups are mandatory for an Edge stack") } return nil } @@ -34,8 +37,8 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) + if err == bolterrors.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} @@ -48,27 +51,27 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) } if payload.EdgeGroups != nil { - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} } - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().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) + oldRelated, err := edge.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) + newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} } @@ -84,14 +87,14 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) } for endpointID := range endpointsToRemove { - relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + relation, err := handler.DataStore.EndpointRelation().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) + err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} } @@ -105,14 +108,14 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) } for endpointID := range endpointsToAdd { - relation, err := handler.EndpointRelationService.EndpointRelation(endpointID) + relation, err := handler.DataStore.EndpointRelation().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) + err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} } @@ -137,7 +140,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} } - err = handler.EdgeStackService.UpdateEdgeStack(stack.ID, stack) + err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 45c823e5c..2e0580d6d 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -12,14 +12,10 @@ import ( // 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 + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService } // NewHandler creates a handler to manage endpoint group operations. @@ -29,17 +25,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { requestBouncer: bouncer, } h.Handle("/edge_stacks", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackCreate))).Methods(http.MethodPost) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost) h.Handle("/edge_stacks", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackList))).Methods(http.MethodGet) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet) h.Handle("/edge_stacks/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackInspect))).Methods(http.MethodGet) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackInspect)))).Methods(http.MethodGet) h.Handle("/edge_stacks/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackUpdate))).Methods(http.MethodPut) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackUpdate)))).Methods(http.MethodPut) h.Handle("/edge_stacks/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackDelete))).Methods(http.MethodDelete) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackDelete)))).Methods(http.MethodDelete) h.Handle("/edge_stacks/{id}/file", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackFile))).Methods(http.MethodGet) + bouncer.AdminAccess(bouncer.EdgeComputeOperation(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 index 4b82f19c2..00f271dbb 100644 --- a/api/http/handler/edgetemplates/edgetemplate_list.go +++ b/api/http/handler/edgetemplates/edgetemplate_list.go @@ -2,7 +2,6 @@ package edgetemplates import ( "encoding/json" - "log" "net/http" httperror "github.com/portainer/libhttp/error" @@ -11,35 +10,39 @@ import ( "github.com/portainer/portainer/api/http/client" ) +type templateFileFormat struct { + Version string `json:"version"` + Templates []portainer.Template `json:"templates"` +} + // GET request on /api/edgetemplates func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } - url := portainer.EdgeTemplatesURL + url := portainer.DefaultTemplatesURL if settings.TemplatesURL != "" { url = settings.TemplatesURL } var templateData []byte - templateData, err = client.Get(url, 0) + templateData, err = client.Get(url, 10) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} } - var templates []portainer.Template + var templateFile templateFileFormat - err = json.Unmarshal(templateData, &templates) + err = json.Unmarshal(templateData, &templateFile) 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse template file", err} } - filteredTemplates := []portainer.Template{} + filteredTemplates := make([]portainer.Template, 0) - for _, template := range templates { + for _, template := range templateFile.Templates { if template.Type == portainer.EdgeStackTemplate { filteredTemplates = append(filteredTemplates, template) } diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go index 75473c49a..963ddb931 100644 --- a/api/http/handler/edgetemplates/handler.go +++ b/api/http/handler/edgetemplates/handler.go @@ -13,8 +13,8 @@ import ( // Handler is the HTTP handler used to handle edge endpoint operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - SettingsService portainer.SettingsService + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore } // NewHandler creates a handler to manage endpoint operations. diff --git a/api/http/handler/endpointedge/endpoint_edgejob_logs.go b/api/http/handler/endpointedge/endpoint_edgejob_logs.go new file mode 100644 index 000000000..d433f5789 --- /dev/null +++ b/api/http/handler/endpointedge/endpoint_edgejob_logs.go @@ -0,0 +1,78 @@ +package endpointedge + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +type logsPayload struct { + FileContent string +} + +func (payload *logsPayload) Validate(r *http.Request) error { + return nil +} + +// POST request on api/endpoints/:id/edge/jobs/:jobID/logs +func (handler *Handler) endpointEdgeJobsLogs(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.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} + } + + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge job identifier route variable", err} + } + + var payload logsPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge job with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge job with the specified identifier inside the database", err} + } + + err = handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(edgeJobID), strconv.Itoa(endpointID), []byte(payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to save task log to the filesystem", err} + } + + meta := edgeJob.Endpoints[endpoint.ID] + meta.CollectLogs = false + meta.LogsStatus = portainer.EdgeJobLogsStatusCollected + edgeJob.Endpoints[endpoint.ID] = meta + + err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob) + + handler.ReverseTunnelService.AddEdgeJob(endpoint.ID, edgeJob) + + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist edge job changes to the database", err} + } + + return response.JSON(w, nil) +} diff --git a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go index 3853e3bba..9f57e115f 100644 --- a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) type configResponse struct { @@ -23,8 +24,8 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -40,8 +41,8 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge stack identifier route variable", err} } - edgeStack, err := handler.EdgeStackService.EdgeStack(portainer.EdgeStackID(edgeStackID)) - if err == portainer.ErrObjectNotFound { + edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err == errors.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} diff --git a/api/http/handler/endpointedge/handler.go b/api/http/handler/endpointedge/handler.go index e8dfc2995..6ca96d3fa 100644 --- a/api/http/handler/endpointedge/handler.go +++ b/api/http/handler/endpointedge/handler.go @@ -13,10 +13,10 @@ import ( // 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 + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + ReverseTunnelService portainer.ReverseTunnelService } // NewHandler creates a handler to manage endpoint operations. @@ -28,6 +28,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/{id}/edge/stacks/{stackId}", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet) - + h.Handle("/{id}/edge/jobs/{jobID}/logs", + bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeJobsLogs))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index f296fee64..f50bf9736 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -1,6 +1,7 @@ package endpointgroups import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -19,7 +20,7 @@ type endpointGroupCreatePayload struct { func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid endpoint group name") + return errors.New("Invalid endpoint group name") } if payload.TagIDs == nil { payload.TagIDs = []portainer.TagID{} @@ -43,12 +44,12 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque TagIDs: payload.TagIDs, } - err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + err = handler.DataStore.EndpointGroup().CreateEndpointGroup(endpointGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the endpoint group inside the database", err} } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } @@ -58,7 +59,7 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque if endpoint.ID == id { endpoint.GroupID = endpointGroup.ID - err := handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err := handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } @@ -74,14 +75,14 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque } for _, tagID := range endpointGroup.TagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tagID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 76cd4ce86..2b845070c 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -1,12 +1,14 @@ package endpointgroups import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/endpoint_groups/:id @@ -17,32 +19,30 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } if endpointGroupID == 1 { - return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} + return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", errors.New("Cannot remove the default endpoint group")} } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrObjectNotFound { + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} } - err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + err = handler.DataStore.EndpointGroup().DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the endpoint group from the database", err} } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } - updateAuthorizations := false for _, endpoint := range endpoints { if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { - updateAuthorizations = true endpoint.GroupID = portainer.EndpointGroupID(1) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } @@ -54,22 +54,15 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } - if updateAuthorizations { - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - } - for _, tagID := range endpointGroup.TagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tagID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index f2435ab33..cce2e737a 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // PUT request on /api/endpoint_groups/:id/endpoints/:endpointId @@ -21,15 +22,15 @@ func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrObjectNotFound { + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -37,7 +38,7 @@ func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http. endpoint.GroupID = endpointGroup.ID - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index 0e4a21611..595be6df9 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/endpoint_groups/:id/endpoints/:endpointId @@ -21,15 +22,15 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrObjectNotFound { + _, err = handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -37,7 +38,7 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht endpoint.GroupID = portainer.EndpointGroupID(1) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go index 3ddc464e7..fdf26a976 100644 --- a/api/http/handler/endpointgroups/endpointgroup_inspect.go +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // GET request on /api/endpoint_groups/:id @@ -16,8 +17,8 @@ func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrObjectNotFound { + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go index 7ea73d2d5..e4e1bb2da 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list.go +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -10,7 +10,7 @@ import ( // GET request on /api/endpoint_groups func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 362b8b697..047bbb6b4 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -8,6 +8,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/tag" ) type endpointGroupUpdatePayload struct { @@ -35,8 +37,8 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrObjectNotFound { + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} @@ -52,22 +54,22 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque 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) + payloadTagSet := tag.Set(payload.TagIDs) + endpointGroupTagSet := tag.Set((endpointGroup.TagIDs)) + union := tag.Union(payloadTagSet, endpointGroupTagSet) + intersection := tag.Intersection(payloadTagSet, endpointGroupTagSet) tagsChanged = len(union) > len(intersection) if tagsChanged { - removeTags := portainer.TagDifference(endpointGroupTagSet, payloadTagSet) + removeTags := tag.Difference(endpointGroupTagSet, payloadTagSet) for tagID := range removeTags { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tag.ID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } @@ -75,14 +77,14 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.TagIDs = payload.TagIDs for _, tagID := range payload.TagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tag.ID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } @@ -90,31 +92,21 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } } - updateAuthorizations := false if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) { endpointGroup.UserAccessPolicies = payload.UserAccessPolicies - updateAuthorizations = true } if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) { endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies - updateAuthorizations = true } - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} } - if updateAuthorizations { - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - } - if tagsChanged { - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go index 11e760e15..c1757d3c7 100644 --- a/api/http/handler/endpointgroups/endpoints.go +++ b/api/http/handler/endpointgroups/endpoints.go @@ -1,14 +1,17 @@ package endpointgroups -import portainer "github.com/portainer/portainer/api" +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/edge" +) func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) error { - if endpoint.Type != portainer.EdgeAgentEnvironment { + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { return nil } if endpointGroup == nil { - unassignedGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(1)) + unassignedGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(1)) if err != nil { return err } @@ -16,27 +19,27 @@ func (handler *Handler) updateEndpointRelations(endpoint *portainer.Endpoint, en endpointGroup = unassignedGroup } - endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { return err } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return err } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return err } - endpointStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + endpointStacks := edge.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) + return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) } diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index a738a2dc1..e73828814 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -12,13 +12,7 @@ import ( // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - AuthorizationService *portainer.AuthorizationService - EdgeGroupService portainer.EdgeGroupService - EdgeStackService portainer.EdgeStackService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - EndpointRelationService portainer.EndpointRelationService - TagService portainer.TagService + DataStore portainer.DataStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index be89bb750..037cb4dfb 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -11,9 +11,8 @@ import ( // Handler is the HTTP handler used to proxy requests to external APIs. type Handler struct { *mux.Router + DataStore portainer.DataStore requestBouncer *security.RequestBouncer - EndpointService portainer.EndpointService - SettingsService portainer.SettingsService ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService } @@ -28,6 +27,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) h.PathPrefix("/{id}/docker").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/kubernetes").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI))) h.PathPrefix("/{id}/storidge").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) return h diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index 6ffc4598a..8176edf8a 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -6,6 +6,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "net/http" ) @@ -16,14 +17,14 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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.AuthorizedEndpointOperation(r, endpoint, false) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index d84aacc62..c4671506d 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -8,6 +8,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" "net/http" ) @@ -18,19 +19,19 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { if endpoint.EdgeID == "" { return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")} } @@ -44,7 +45,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update tunnel status", err} } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go new file mode 100644 index 000000000..7a4e9dc01 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -0,0 +1,74 @@ +package endpointproxy + +import ( + "errors" + "fmt" + "time" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + + "net/http" +) + +func (handler *Handler) proxyRequestsToKubernetesAPI(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.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + if endpoint.EdgeID == "" { + return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")} + } + + tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) + if tunnel.Status == portainer.EdgeAgentIdle { + handler.ProxyManager.DeleteEndpointProxy(endpoint) + + err := handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update tunnel status", err} + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second + time.Sleep(waitForAgentToConnect * 2) + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID) + if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + requestPrefix = fmt.Sprintf("/%d", endpointID) + } + + http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index f2d2aacb1..6cc59ff17 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -3,11 +3,13 @@ package endpointproxy // TODO: legacy extension management import ( + "errors" "strconv" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" "net/http" ) @@ -18,14 +20,14 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, false) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -38,7 +40,7 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt } if storidgeExtension == nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", portainer.ErrEndpointExtensionNotSupported} + return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", errors.New("This extension is not supported")} } proxyExtensionKey := strconv.Itoa(endpointID) + "_" + strconv.Itoa(int(portainer.StoridgeEndpointExtension)) + "_" + storidgeExtension.URL diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index b7d08f320..728711d87 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -2,12 +2,14 @@ package endpoints import ( "errors" + "fmt" "net" "net/http" "net/url" "runtime" "strconv" "strings" + "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -15,12 +17,13 @@ import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/edge" ) type endpointCreatePayload struct { Name string URL string - EndpointType int + EndpointCreationType endpointCreationEnum PublicURL string GroupID int TLS bool @@ -33,20 +36,32 @@ type endpointCreatePayload struct { AzureTenantID string AzureAuthenticationKey string TagIDs []portainer.TagID + EdgeCheckinInterval int } +type endpointCreationEnum int + +const ( + _ endpointCreationEnum = iota + localDockerEnvironment + agentEnvironment + azureEnvironment + edgeAgentEnvironment + localKubernetesEnvironment +) + func (payload *endpointCreatePayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return portainer.Error("Invalid endpoint name") + return errors.New("Invalid endpoint name") } payload.Name = name - endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) - if err != nil || endpointType == 0 { - return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge Agent environment)") + endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false) + if err != nil || endpointCreationType == 0 { + return errors.New("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)") } - payload.EndpointType = endpointType + payload.EndpointCreationType = endpointCreationEnum(endpointCreationType) groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true) if groupID == 0 { @@ -57,7 +72,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { var tagIDs []portainer.TagID err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true) if err != nil { - return portainer.Error("Invalid TagIds parameter") + return errors.New("Invalid TagIds parameter") } payload.TagIDs = tagIDs if payload.TagIDs == nil { @@ -76,7 +91,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { if !payload.TLSSkipVerify { caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") if err != nil { - return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + return errors.New("Invalid CA certificate file. Ensure that the file is uploaded correctly") } payload.TLSCACertFile = caCert } @@ -84,57 +99,56 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { if !payload.TLSSkipClientVerify { cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") if err != nil { - return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + return errors.New("Invalid certificate file. Ensure that the file is uploaded correctly") } payload.TLSCertFile = cert key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") if err != nil { - return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + return errors.New("Invalid key file. Ensure that the file is uploaded correctly") } payload.TLSKeyFile = key } } - switch portainer.EndpointType(payload.EndpointType) { - case portainer.AzureEnvironment: + switch payload.EndpointCreationType { + case azureEnvironment: azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) if err != nil { - return portainer.Error("Invalid Azure application ID") + return errors.New("Invalid Azure application ID") } payload.AzureApplicationID = azureApplicationID azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) if err != nil { - return portainer.Error("Invalid Azure tenant ID") + return errors.New("Invalid Azure tenant ID") } payload.AzureTenantID = azureTenantID azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) if err != nil { - return portainer.Error("Invalid Azure authentication key") + return errors.New("Invalid Azure authentication key") } payload.AzureAuthenticationKey = azureAuthenticationKey default: - url, err := request.RetrieveMultiPartFormValue(r, "URL", true) + endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true) if err != nil { - return portainer.Error("Invalid endpoint URL") + return errors.New("Invalid endpoint URL") } - payload.URL = url + payload.URL = endpointURL publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) payload.PublicURL = publicURL } + checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) + payload.EdgeCheckinInterval = checkinInterval + return nil } // POST request on /api/endpoints func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - if !handler.authorizeEndpointManagement { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} - } - payload := &endpointCreatePayload{} err := payload.Validate(r) if err != nil { @@ -146,17 +160,17 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * return endpointCreationError } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + endpointGroup, err := handler.DataStore.EndpointGroup().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() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} } @@ -166,14 +180,14 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * EdgeStacks: map[portainer.EdgeStackID]bool{}, } - if endpoint.Type == portainer.EdgeAgentEnvironment { - relatedEdgeStacks := portainer.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + relatedEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) for _, stackID := range relatedEdgeStacks { relationObject.EdgeStacks[stackID] = true } } - err = handler.EndpointRelationService.CreateEndpointRelation(relationObject) + err = handler.DataStore.EndpointRelation().CreateEndpointRelation(relationObject) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the relation object inside the database", err} } @@ -182,14 +196,34 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { - if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + switch payload.EndpointCreationType { + case azureEnvironment: return handler.createAzureEndpoint(payload) - } else if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { + + case edgeAgentEnvironment: return handler.createEdgeAgentEndpoint(payload) + + case localKubernetesEnvironment: + return handler.createKubernetesEndpoint(payload) + } + + endpointType := portainer.DockerEnvironment + if payload.EndpointCreationType == agentEnvironment { + agentPlatform, err := handler.pingAndCheckPlatform(payload) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to get endpoint type", err} + } + + if agentPlatform == portainer.AgentPlatformDocker { + endpointType = portainer.AgentOnDockerEnvironment + } else if agentPlatform == portainer.AgentPlatformKubernetes { + endpointType = portainer.AgentOnKubernetesEnvironment + payload.URL = strings.TrimPrefix(payload.URL, "tcp://") + } } if payload.TLS { - return handler.createTLSSecuredEndpoint(payload) + return handler.createTLSSecuredEndpoint(payload, endpointType) } return handler.createUnsecuredEndpoint(payload) } @@ -207,7 +241,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} } - endpointID := handler.EndpointService.GetNextIdentifier() + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), Name: payload.Name, @@ -221,7 +255,8 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po AzureCredentials: credentials, TagIDs: payload.TagIDs, Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), } err = handler.saveEndpointAndUpdateAuthorizations(endpoint) @@ -233,8 +268,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po } func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { - endpointType := portainer.EdgeAgentEnvironment - endpointID := handler.EndpointService.GetNextIdentifier() + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() portainerURL, err := url.Parse(payload.URL) if err != nil { @@ -256,18 +290,20 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) ID: portainer.EndpointID(endpointID), Name: payload.Name, URL: portainerHost, - Type: endpointType, + Type: portainer.EdgeAgentOnDockerEnvironment, GroupID: portainer.EndpointGroupID(payload.GroupID), TLSConfig: portainer.TLSConfiguration{ TLS: false, }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - TagIDs: payload.TagIDs, - Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, - EdgeKey: edgeKey, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + EdgeKey: edgeKey, + EdgeCheckinInterval: payload.EdgeCheckinInterval, + Kubernetes: portainer.KubernetesDefault(), } err = handler.saveEndpointAndUpdateAuthorizations(endpoint) @@ -286,17 +322,9 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) if runtime.GOOS == "windows" { payload.URL = "npipe:////./pipe/docker_engine" } - } else { - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil) - if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} - } - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } } - endpointID := handler.EndpointService.GetNextIdentifier() + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), Name: payload.Name, @@ -312,7 +340,8 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) Extensions: []portainer.EndpointExtension{}, TagIDs: payload.TagIDs, Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), } err := handler.snapshotAndPersistEndpoint(endpoint) @@ -323,23 +352,43 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) return endpoint, nil } -func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { - tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipClientVerify, payload.TLSSkipVerify) +func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + if payload.URL == "" { + payload.URL = "https://kubernetes.default.svc" + } + + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() + + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: payload.URL, + Type: portainer.KubernetesLocalEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + Extensions: []portainer.EndpointExtension{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + err := handler.snapshotAndPersistEndpoint(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to create TLS configuration", err} + return nil, err } - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, tlsConfig) - if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} - } + return endpoint, nil +} - endpointType := portainer.DockerEnvironment - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - - endpointID := handler.EndpointService.GetNextIdentifier() +func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, endpointType portainer.EndpointType) (*portainer.Endpoint, *httperror.HandlerError) { + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), Name: payload.Name, @@ -356,25 +405,25 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) Extensions: []portainer.EndpointExtension{}, TagIDs: payload.TagIDs, Status: portainer.EndpointStatusUp, - Snapshots: []portainer.Snapshot{}, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), } - filesystemError := handler.storeTLSFiles(endpoint, payload) + err := handler.storeTLSFiles(endpoint, payload) if err != nil { - return nil, filesystemError + return nil, err } - endpointCreationError := handler.snapshotAndPersistEndpoint(endpoint) - if endpointCreationError != nil { - return nil, endpointCreationError + err = handler.snapshotAndPersistEndpoint(endpoint) + if err != nil { + return nil, err } return endpoint, nil } func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) *httperror.HandlerError { - snapshot, err := handler.Snapshotter.CreateSnapshot(endpoint) - endpoint.Status = portainer.EndpointStatusUp + err := handler.SnapshotService.SnapshotEndpoint(endpoint) if err != nil { if strings.Contains(err.Error(), "Invalid request signature") { err = errors.New("agent already paired with another Portainer instance") @@ -382,10 +431,6 @@ func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to initiate communications with endpoint", err} } - if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} - } - err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} @@ -395,29 +440,20 @@ func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) } func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.Endpoint) error { - err := handler.EndpointService.CreateEndpoint(endpoint) + err := handler.DataStore.Endpoint().CreateEndpoint(endpoint) if err != nil { return err } - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - return err - } - - if len(group.UserAccessPolicies) > 0 || len(group.TeamAccessPolicies) > 0 { - return handler.AuthorizationService.UpdateUsersAuthorizations() - } - for _, tagID := range endpoint.TagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().Tag(tagID) if err != nil { return err } tag.Endpoints[endpoint.ID] = true - err = handler.TagService.UpdateTag(tagID, tag) + err = handler.DataStore.Tag().UpdateTag(tagID, tag) if err != nil { return err } @@ -453,3 +489,58 @@ func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *end return nil } + +func (handler *Handler) pingAndCheckPlatform(payload *endpointCreatePayload) (portainer.AgentPlatform, error) { + httpCli := &http.Client{ + Timeout: 3 * time.Second, + } + + if payload.TLS { + tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify) + if err != nil { + return 0, err + } + + httpCli.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + } + + url, err := url.Parse(fmt.Sprintf("%s/ping", payload.URL)) + if err != nil { + return 0, err + } + + url.Scheme = "https" + + req, err := http.NewRequest(http.MethodGet, url.String(), nil) + if err != nil { + return 0, err + } + + resp, err := httpCli.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return 0, fmt.Errorf("Failed request with status %d", resp.StatusCode) + } + + agentPlatformHeader := resp.Header.Get(portainer.HTTPResponseAgentPlatform) + if agentPlatformHeader == "" { + return 0, errors.New("Agent Platform Header is missing") + } + + agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader) + if err != nil { + return 0, err + } + + if agentPlatformNumber == 0 { + return 0, errors.New("Agent platform is invalid") + } + + return portainer.AgentPlatform(agentPlatformNumber), nil +} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 43b20dc78..875d4153e 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -8,21 +8,18 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/endpoints/:id func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - if !handler.authorizeEndpointManagement { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} - } - endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -36,40 +33,33 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } } - err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) + err = handler.DataStore.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} } handler.ProxyManager.DeleteEndpointProxy(endpoint) - if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - } - - err = handler.EndpointRelationService.DeleteEndpointRelation(endpoint.ID) + err = handler.DataStore.EndpointRelation().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) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().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() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} } @@ -79,14 +69,14 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * endpointIdx := findEndpointIndex(edgeGroup.Endpoints, endpoint.ID) if endpointIdx != -1 { edgeGroup.Endpoints = removeElement(edgeGroup.Endpoints, endpointIdx) - err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge group", err} } } } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stacks from the database", err} } @@ -95,7 +85,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * edgeStack := &edgeStacks[idx] if _, ok := edgeStack.Status[endpoint.ID]; ok { delete(edgeStack.Status, endpoint.ID) - err = handler.EdgeStackService.UpdateEdgeStack(edgeStack.ID, edgeStack) + err = handler.DataStore.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update edge stack", err} } diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index f91a714c3..e99e10caa 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -3,6 +3,7 @@ package endpoints // TODO: legacy extension management import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -10,6 +11,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type endpointExtensionAddPayload struct { @@ -19,10 +21,10 @@ type endpointExtensionAddPayload struct { func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { if payload.Type != 1 { - return portainer.Error("Invalid type value. Value must be one of: 1 (Storidge)") + return errors.New("Invalid type value. Value must be one of: 1 (Storidge)") } if payload.Type == 1 && govalidator.IsNull(payload.URL) { - return portainer.Error("Invalid extension URL") + return errors.New("Invalid extension URL") } return nil } @@ -34,8 +36,8 @@ func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} @@ -66,7 +68,7 @@ func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Requ endpoint.Extensions = append(endpoint.Extensions, *extension) } - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index b426071e0..99edf1bc8 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/endpoints/:id/extensions/:extensionType @@ -18,8 +19,8 @@ func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -36,7 +37,7 @@ func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.R } } - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 10cf34ed9..1411e93cb 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // GET request on /api/endpoints/:id @@ -16,14 +17,14 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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.AuthorizedEndpointOperation(r, endpoint, false) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go deleted file mode 100644 index 78d00bc9c..000000000 --- a/api/http/handler/endpoints/endpoint_job.go +++ /dev/null @@ -1,111 +0,0 @@ -package endpoints - -import ( - "errors" - "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 endpointJobFromFilePayload struct { - Image string - File []byte -} - -type endpointJobFromFileContentPayload struct { - Image string - FileContent string -} - -func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error { - file, _, err := request.RetrieveMultiPartFormFile(r, "File") - if err != nil { - return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly") - } - payload.File = file - - image, err := request.RetrieveMultiPartFormValue(r, "Image", false) - if err != nil { - return portainer.Error("Invalid image name") - } - payload.Image = image - - return nil -} - -func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.FileContent) { - return portainer.Error("Invalid script file content") - } - - if govalidator.IsNull(payload.Image) { - return portainer.Error("Invalid image name") - } - - return nil -} - -// POST request on /api/endpoints/:id/job?method&nodeName -func (handler *Handler) endpointJob(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} - } - - method, err := request.RetrieveQueryParameter(r, "method", false) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} - } - - nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true) - - 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} - } - - switch method { - case "file": - return handler.executeJobFromFile(w, r, endpoint, nodeName) - case "string": - return handler.executeJobFromFileContent(w, r, endpoint, nodeName) - } - - return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)} -} - -func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { - payload := &endpointJobFromFilePayload{} - err := payload.Validate(r) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} - } - - return response.Empty(w) -} - -func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError { - var payload endpointJobFromFileContentPayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 57ffacbce..fe7c489ae 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -38,12 +38,12 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht var endpointIDs []portainer.EndpointID request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true) - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } @@ -64,7 +64,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht } if search != "" { - tags, err := handler.TagService.Tags() + tags, err := handler.DataStore.Tag().Tags() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} } diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index de3eba46c..e834fea5f 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/snapshot" ) // POST request on /api/endpoints/:id/snapshot @@ -16,20 +18,20 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if endpoint.Type == portainer.AzureEnvironment { - return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + if !snapshot.SupportDirectSnapshot(endpoint) { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this endpoint", err} } - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) + snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint) - latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) if latestEndpointReference == nil { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } @@ -39,11 +41,10 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) latestEndpointReference.Status = portainer.EndpointStatusDown } - if snapshot != nil { - latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} - } + latestEndpointReference.Snapshots = endpoint.Snapshots + latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots - err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index e25be6f89..3fd83d071 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -7,39 +7,39 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/snapshot" ) // POST request on /api/endpoints/snapshot func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment { + if !snapshot.SupportDirectSnapshot(&endpoint) { continue } - snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) + snapshotError := handler.SnapshotService.SnapshotEndpoint(&endpoint) - latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID) + latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) if latestEndpointReference == nil { log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) continue } - latestEndpointReference.Status = portainer.EndpointStatusUp + endpoint.Status = portainer.EndpointStatusUp if snapshotError != nil { log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) - latestEndpointReference.Status = portainer.EndpointStatusDown + endpoint.Status = portainer.EndpointStatusDown } - if snapshot != nil { - latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} - } + latestEndpointReference.Snapshots = endpoint.Snapshots + latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots - err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index 13ae819a0..787af6788 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -1,12 +1,16 @@ package endpoints import ( + "encoding/base64" + "errors" "net/http" + "strconv" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type stackStatusResponse struct { @@ -14,13 +18,21 @@ type stackStatusResponse struct { Version int } +type edgeJobResponse struct { + ID portainer.EdgeJobID `json:"Id"` + CollectLogs bool `json:"CollectLogs"` + CronExpression string `json:"CronExpression"` + Script string `json:"Script"` + Version int `json:"Version"` +} + 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"` + Status string `json:"status"` + Port int `json:"port"` + Schedules []edgeJobResponse `json:"schedules"` + CheckinInterval int `json:"checkin"` + Credentials string `json:"credentials"` + Stacks []stackStatusResponse `json:"stacks"` } // GET request on /api/endpoints/:id/status @@ -30,8 +42,8 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} @@ -44,27 +56,69 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req if endpoint.EdgeID == "" { edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) - endpoint.EdgeID = edgeIdentifier - err := handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + agentPlatformHeader := r.Header.Get(portainer.HTTPResponseAgentPlatform) + if agentPlatformHeader == "" { + return &httperror.HandlerError{http.StatusInternalServerError, "Agent Platform Header is missing", errors.New("Agent Platform Header is missing")} + } + + agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse agent platform header", err} + } + + agentPlatform := portainer.AgentPlatform(agentPlatformNumber) + + if agentPlatform == portainer.AgentPlatformDocker { + endpoint.Type = portainer.EdgeAgentOnDockerEnvironment + } else if agentPlatform == portainer.AgentPlatformKubernetes { + endpoint.Type = portainer.EdgeAgentOnKubernetesEnvironment + } + + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to Unable to persist endpoint changes inside the database", err} } } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) + checkinInterval := settings.EdgeAgentCheckinInterval + if endpoint.EdgeCheckinInterval != 0 { + checkinInterval = endpoint.EdgeCheckinInterval + } + + schedules := []edgeJobResponse{} + for _, job := range tunnel.Jobs { + schedule := edgeJobResponse{ + ID: job.ID, + CronExpression: job.CronExpression, + CollectLogs: job.Endpoints[endpoint.ID].CollectLogs, + Version: job.Version, + } + + file, err := handler.FileService.GetFileContent(job.ScriptPath) + + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge job script file", err} + } + + schedule.Script = base64.RawStdEncoding.EncodeToString(file) + + schedules = append(schedules, schedule) + } + statusResponse := endpointStatusInspectResponse{ Status: tunnel.Status, Port: tunnel.Port, - Schedules: tunnel.Schedules, - CheckinInterval: settings.EdgeAgentCheckinInterval, + Schedules: schedules, + CheckinInterval: checkinInterval, Credentials: tunnel.Credentials, } @@ -72,14 +126,14 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID) } - relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + relation, err := handler.DataStore.EndpointRelation().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) + stack, err := handler.DataStore.EdgeStack().EdgeStack(stackID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack from the database", err} } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 2cc521a47..172c8a45c 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -9,7 +9,10 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/tag" ) type endpointUpdatePayload struct { @@ -27,6 +30,8 @@ type endpointUpdatePayload struct { TagIDs []portainer.TagID UserAccessPolicies portainer.UserAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies + EdgeCheckinInterval *int + Kubernetes *portainer.KubernetesData } func (payload *endpointUpdatePayload) Validate(r *http.Request) error { @@ -35,10 +40,6 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error { // PUT request on /api/endpoints/:id func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - if !handler.authorizeEndpointManagement { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} - } - endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} @@ -50,8 +51,8 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.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} @@ -69,6 +70,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.PublicURL = *payload.PublicURL } + if payload.EdgeCheckinInterval != nil { + endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval + } + groupIDChanged := false if payload.GroupID != nil { groupID := portainer.EndpointGroupID(*payload.GroupID) @@ -78,23 +83,23 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * 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) + payloadTagSet := tag.Set(payload.TagIDs) + endpointTagSet := tag.Set((endpoint.TagIDs)) + union := tag.Union(payloadTagSet, endpointTagSet) + intersection := tag.Intersection(payloadTagSet, endpointTagSet) tagsChanged = len(union) > len(intersection) if tagsChanged { - removeTags := portainer.TagDifference(endpointTagSet, payloadTagSet) + removeTags := tag.Difference(endpointTagSet, payloadTagSet) for tagID := range removeTags { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tag.ID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } @@ -102,14 +107,14 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.TagIDs = payload.TagIDs for _, tagID := range payload.TagIDs { - tag, err := handler.TagService.Tag(tagID) + tag, err := handler.DataStore.Tag().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) + err = handler.DataStore.Tag().UpdateTag(tag.ID, tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist tag changes inside the database", err} } @@ -117,15 +122,16 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } - updateAuthorizations := false + if payload.Kubernetes != nil { + endpoint.Kubernetes = *payload.Kubernetes + } + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) { endpoint.UserAccessPolicies = payload.UserAccessPolicies - updateAuthorizations = true } if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) { endpoint.TeamAccessPolicies = payload.TeamAccessPolicies - updateAuthorizations = true } if payload.Status != nil { @@ -212,49 +218,42 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } - if updateAuthorizations { - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - } - - if endpoint.Type == portainer.EdgeAgentEnvironment && (groupIDChanged || tagsChanged) { - relation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + if (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && (groupIDChanged || tagsChanged) { + relation, err := handler.DataStore.EndpointRelation().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) + endpointGroup, err := handler.DataStore.EndpointGroup().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() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().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) + endpointEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) for _, edgeStackID := range endpointEdgeStacks { edgeStackSet[edgeStackID] = true } relation.EdgeStacks = edgeStackSet - err = handler.EndpointRelationService.UpdateEndpointRelation(endpoint.ID, relation) + err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation changes inside the database", err} } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index bca5dea75..c004b5751 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -2,7 +2,7 @@ package endpoints import ( httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" @@ -11,45 +11,29 @@ import ( "github.com/gorilla/mux" ) -const ( - // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints - // when the server has been started with the --external-endpoints flag - ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") -) - func hideFields(endpoint *portainer.Endpoint) { endpoint.AzureCredentials = portainer.AzureCredentials{} if len(endpoint.Snapshots) > 0 { - endpoint.Snapshots[0].SnapshotRaw = portainer.SnapshotRaw{} + endpoint.Snapshots[0].SnapshotRaw = portainer.DockerSnapshotRaw{} } } // Handler is the HTTP handler used to handle endpoint operations. 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 - JobService portainer.JobService - ProxyManager *proxy.Manager - ReverseTunnelService portainer.ReverseTunnelService - SettingsService portainer.SettingsService - Snapshotter portainer.Snapshotter - TagService portainer.TagService + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager + ReverseTunnelService portainer.ReverseTunnelService + SnapshotService portainer.SnapshotService } // NewHandler creates a handler to manage endpoint operations. -func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *Handler { +func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ - Router: mux.NewRouter(), - authorizeEndpointManagement: authorizeEndpointManagement, - requestBouncer: bouncer, + Router: mux.NewRouter(), + requestBouncer: bouncer, } h.Handle("/endpoints", @@ -68,8 +52,6 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/extensions/{extensionType}", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) - h.Handle("/endpoints/{id}/job", - bouncer.AdminAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/snapshot", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/status", diff --git a/api/http/handler/extensions/data.go b/api/http/handler/extensions/data.go deleted file mode 100644 index 8c950e608..000000000 --- a/api/http/handler/extensions/data.go +++ /dev/null @@ -1,117 +0,0 @@ -package extensions - -import ( - portainer "github.com/portainer/portainer/api" -) - -func updateUserAccessPolicyToReadOnlyRole(policies portainer.UserAccessPolicies, key portainer.UserID) { - tmp := policies[key] - tmp.RoleID = 4 - policies[key] = tmp -} - -func updateTeamAccessPolicyToReadOnlyRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) { - tmp := policies[key] - tmp.RoleID = 4 - policies[key] = tmp -} - -func (handler *Handler) upgradeRBACData() error { - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - return err - } - - for _, endpointGroup := range endpointGroups { - for key := range endpointGroup.UserAccessPolicies { - updateUserAccessPolicyToReadOnlyRole(endpointGroup.UserAccessPolicies, key) - } - - for key := range endpointGroup.TeamAccessPolicies { - updateTeamAccessPolicyToReadOnlyRole(endpointGroup.TeamAccessPolicies, key) - } - - err := handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) - if err != nil { - return err - } - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - for key := range endpoint.UserAccessPolicies { - updateUserAccessPolicyToReadOnlyRole(endpoint.UserAccessPolicies, key) - } - - for key := range endpoint.TeamAccessPolicies { - updateTeamAccessPolicyToReadOnlyRole(endpoint.TeamAccessPolicies, key) - } - - err := handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return err - } - } - - return handler.AuthorizationService.UpdateUsersAuthorizations() -} - -func updateUserAccessPolicyToNoRole(policies portainer.UserAccessPolicies, key portainer.UserID) { - tmp := policies[key] - tmp.RoleID = 0 - policies[key] = tmp -} - -func updateTeamAccessPolicyToNoRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) { - tmp := policies[key] - tmp.RoleID = 0 - policies[key] = tmp -} - -func (handler *Handler) downgradeRBACData() error { - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - return err - } - - for _, endpointGroup := range endpointGroups { - for key := range endpointGroup.UserAccessPolicies { - updateUserAccessPolicyToNoRole(endpointGroup.UserAccessPolicies, key) - } - - for key := range endpointGroup.TeamAccessPolicies { - updateTeamAccessPolicyToNoRole(endpointGroup.TeamAccessPolicies, key) - } - - err := handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) - if err != nil { - return err - } - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - for key := range endpoint.UserAccessPolicies { - updateUserAccessPolicyToNoRole(endpoint.UserAccessPolicies, key) - } - - for key := range endpoint.TeamAccessPolicies { - updateTeamAccessPolicyToNoRole(endpoint.TeamAccessPolicies, key) - } - - err := handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return err - } - } - - return handler.AuthorizationService.UpdateUsersAuthorizations() -} diff --git a/api/http/handler/extensions/extension_create.go b/api/http/handler/extensions/extension_create.go deleted file mode 100644 index 7e41c9595..000000000 --- a/api/http/handler/extensions/extension_create.go +++ /dev/null @@ -1,86 +0,0 @@ -package extensions - -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 extensionCreatePayload struct { - License string -} - -func (payload *extensionCreatePayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.License) { - return portainer.Error("Invalid license") - } - - return nil -} - -func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload extensionCreatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - extensionIdentifier, err := strconv.Atoi(string(payload.License[0])) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err} - } - extensionID := portainer.ExtensionID(extensionIdentifier) - - extensions, err := handler.ExtensionService.Extensions() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err} - } - - for _, existingExtension := range extensions { - if existingExtension.ID == extensionID && existingExtension.Enabled { - return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled} - } - } - - extension := &portainer.Extension{ - ID: extensionID, - } - - extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} - } - - for _, def := range extensionDefinitions { - if def.ID == extension.ID { - extension.Version = def.Version - break - } - } - - err = handler.ExtensionManager.EnableExtension(extension, payload.License) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err} - } - - extension.Enabled = true - - if extension.ID == portainer.RBACExtension { - err = handler.upgradeRBACData() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err} - } - } - - err = handler.ExtensionService.Persist(extension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/extensions/extension_delete.go b/api/http/handler/extensions/extension_delete.go deleted file mode 100644 index 3f9853016..000000000 --- a/api/http/handler/extensions/extension_delete.go +++ /dev/null @@ -1,45 +0,0 @@ -package extensions - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -// DELETE request on /api/extensions/:id -func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} - } - extensionID := portainer.ExtensionID(extensionIdentifier) - - extension, err := handler.ExtensionService.Extension(extensionID) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - err = handler.ExtensionManager.DisableExtension(extension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err} - } - - if extensionID == portainer.RBACExtension { - err = handler.downgradeRBACData() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err} - } - } - - err = handler.ExtensionService.DeleteExtension(extensionID) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/extensions/extension_inspect.go b/api/http/handler/extensions/extension_inspect.go deleted file mode 100644 index 6af8b1940..000000000 --- a/api/http/handler/extensions/extension_inspect.go +++ /dev/null @@ -1,55 +0,0 @@ -package extensions - -import ( - "net/http" - - "github.com/portainer/portainer/api/http/client" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -// GET request on /api/extensions/:id -func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} - } - - extensionID := portainer.ExtensionID(extensionIdentifier) - - definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err} - } - - localExtension, err := handler.ExtensionService.Extension(extensionID) - if err != nil && err != portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension information from the database", err} - } - - var extension portainer.Extension - var extensionDefinition portainer.Extension - - for _, definition := range definitions { - if definition.ID == extensionID { - extensionDefinition = definition - break - } - } - - if localExtension == nil { - extension = extensionDefinition - } else { - extension = *localExtension - } - - mergeExtensionAndDefinition(&extension, &extensionDefinition) - - description, _ := client.Get(extension.DescriptionURL, 5) - extension.Description = string(description) - - return response.JSON(w, extension) -} diff --git a/api/http/handler/extensions/extension_list.go b/api/http/handler/extensions/extension_list.go deleted file mode 100644 index e96e103c8..000000000 --- a/api/http/handler/extensions/extension_list.go +++ /dev/null @@ -1,30 +0,0 @@ -package extensions - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" -) - -// GET request on /api/extensions?store= -func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - fetchManifestInformation, _ := request.RetrieveBooleanQueryParameter(r, "store", true) - - extensions, err := handler.ExtensionService.Extensions() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err} - } - - if fetchManifestInformation { - definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err} - } - - extensions = mergeExtensionsAndDefinitions(extensions, definitions) - } - - return response.JSON(w, extensions) -} diff --git a/api/http/handler/extensions/extension_update.go b/api/http/handler/extensions/extension_update.go deleted file mode 100644 index b51bf93ba..000000000 --- a/api/http/handler/extensions/extension_update.go +++ /dev/null @@ -1,56 +0,0 @@ -package extensions - -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 extensionUpdatePayload struct { - Version string -} - -func (payload *extensionUpdatePayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.Version) { - return portainer.Error("Invalid extension version") - } - - return nil -} - -func (handler *Handler) extensionUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} - } - extensionID := portainer.ExtensionID(extensionIdentifier) - - var payload extensionUpdatePayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - extension, err := handler.ExtensionService.Extension(extensionID) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - err = handler.ExtensionManager.UpdateExtension(extension, payload.Version) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update extension", err} - } - - err = handler.ExtensionService.Persist(extension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/extensions/extension_upload.go b/api/http/handler/extensions/extension_upload.go deleted file mode 100644 index 46d403fc6..000000000 --- a/api/http/handler/extensions/extension_upload.go +++ /dev/null @@ -1,75 +0,0 @@ -package extensions - -import ( - "net/http" - "strconv" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -type extensionUploadPayload struct { - License string - ExtensionArchive []byte - ArchiveFileName string -} - -func (payload *extensionUploadPayload) Validate(r *http.Request) error { - license, err := request.RetrieveMultiPartFormValue(r, "License", false) - if err != nil { - return portainer.Error("Invalid license") - } - payload.License = license - - fileData, fileName, err := request.RetrieveMultiPartFormFile(r, "file") - if err != nil { - return portainer.Error("Invalid extension archive file. Ensure that the file is uploaded correctly") - } - payload.ExtensionArchive = fileData - payload.ArchiveFileName = fileName - - return nil -} - -func (handler *Handler) extensionUpload(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - payload := &extensionUploadPayload{} - err := payload.Validate(r) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - extensionIdentifier, err := strconv.Atoi(string(payload.License[0])) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err} - } - extensionID := portainer.ExtensionID(extensionIdentifier) - - extension := &portainer.Extension{ - ID: extensionID, - } - - _ = handler.ExtensionManager.DisableExtension(extension) - - err = handler.ExtensionManager.InstallExtension(extension, payload.License, payload.ArchiveFileName, payload.ExtensionArchive) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to install extension", err} - } - - extension.Enabled = true - - if extension.ID == portainer.RBACExtension { - err = handler.upgradeRBACData() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err} - } - } - - err = handler.ExtensionService.Persist(extension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go deleted file mode 100644 index 15df2ce17..000000000 --- a/api/http/handler/extensions/handler.go +++ /dev/null @@ -1,86 +0,0 @@ -package extensions - -import ( - "net/http" - - "github.com/coreos/go-semver/semver" - - "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 extension operations. -type Handler struct { - *mux.Router - ExtensionService portainer.ExtensionService - ExtensionManager portainer.ExtensionManager - EndpointGroupService portainer.EndpointGroupService - EndpointService portainer.EndpointService - RegistryService portainer.RegistryService - AuthorizationService *portainer.AuthorizationService -} - -// NewHandler creates a handler to manage extension operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { - h := &Handler{ - Router: mux.NewRouter(), - } - - h.Handle("/extensions", - bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) - h.Handle("/extensions", - bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) - h.Handle("/extensions/upload", - bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpload))).Methods(http.MethodPost) - h.Handle("/extensions/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) - h.Handle("/extensions/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) - h.Handle("/extensions/{id}/update", - bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) - - return h -} - -func mergeExtensionsAndDefinitions(extensions, definitions []portainer.Extension) []portainer.Extension { - for _, definition := range definitions { - foundInDB := false - - for idx, extension := range extensions { - if extension.ID == definition.ID { - foundInDB = true - mergeExtensionAndDefinition(&extensions[idx], &definition) - break - } - } - - if !foundInDB { - extensions = append(extensions, definition) - } - } - - return extensions -} - -func mergeExtensionAndDefinition(extension, definition *portainer.Extension) { - extension.Name = definition.Name - extension.ShortDescription = definition.ShortDescription - extension.Deal = definition.Deal - extension.Available = definition.Available - extension.DescriptionURL = definition.DescriptionURL - extension.Images = definition.Images - extension.Logo = definition.Logo - extension.Price = definition.Price - extension.PriceDescription = definition.PriceDescription - extension.ShopURL = definition.ShopURL - - definitionVersion := semver.New(definition.Version) - extensionVersion := semver.New(extension.Version) - if extensionVersion.LessThan(*definitionVersion) { - extension.UpdateAvailable = true - } - - extension.Version = definition.Version -} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 8b167b12e..0036af8b3 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -4,26 +4,22 @@ import ( "net/http" "strings" + "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/customtemplates" + "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgejobs" "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" - - "github.com/portainer/portainer/api/http/handler/roles" - - "github.com/portainer/portainer/api/http/handler/auth" - "github.com/portainer/portainer/api/http/handler/dockerhub" "github.com/portainer/portainer/api/http/handler/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" - "github.com/portainer/portainer/api/http/handler/extensions" "github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" + "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" @@ -40,8 +36,10 @@ import ( // Handler is a collection of all the service handlers. type Handler struct { AuthHandler *auth.Handler + CustomTemplatesHandler *customtemplates.Handler DockerHubHandler *dockerhub.Handler EdgeGroupsHandler *edgegroups.Handler + EdgeJobsHandler *edgejobs.Handler EdgeStacksHandler *edgestacks.Handler EdgeTemplatesHandler *edgetemplates.Handler EndpointEdgeHandler *endpointedge.Handler @@ -50,15 +48,12 @@ type Handler struct { EndpointProxyHandler *endpointproxy.Handler FileHandler *file.Handler MOTDHandler *motd.Handler - ExtensionHandler *extensions.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler RoleHandler *roles.Handler - SchedulesHanlder *schedules.Handler SettingsHandler *settings.Handler StackHandler *stacks.Handler StatusHandler *status.Handler - SupportHandler *support.Handler TagHandler *tags.Handler TeamMembershipHandler *teammemberships.Handler TeamHandler *teams.Handler @@ -76,10 +71,16 @@ 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/custom_templates"): + http.StripPrefix("/api", h.CustomTemplatesHandler).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_jobs"): + http.StripPrefix("/api", h.EdgeJobsHandler).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_templates"): http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): @@ -88,6 +89,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/docker/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/kubernetes/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/storidge/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/azure/"): @@ -97,8 +100,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } - case strings.HasPrefix(r.URL.Path, "/api/extensions"): - http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/motd"): http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/registries"): @@ -107,16 +108,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/roles"): http.StripPrefix("/api", h.RoleHandler).ServeHTTP(w, r) - case strings.HasPrefix(r.URL.Path, "/api/schedules"): - http.StripPrefix("/api", h.SchedulesHanlder).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/settings"): http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/stacks"): http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) - case strings.HasPrefix(r.URL.Path, "/api/support"): - http.StripPrefix("/api", h.SupportHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/tags"): http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 3b2646dcb..035385346 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -18,11 +18,10 @@ func hideFields(registry *portainer.Registry) { // Handler is the HTTP handler used to handle registry operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - RegistryService portainer.RegistryService - ExtensionService portainer.ExtensionService - FileService portainer.FileService - ProxyManager *proxy.Manager + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager } // NewHandler creates a handler to manage registry operations. @@ -44,10 +43,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) h.Handle("/registries/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) - h.PathPrefix("/registries/{id}/v2").Handler( - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) - h.PathPrefix("/registries/{id}/proxies/gitlab").Handler( - bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithRegistry))) h.PathPrefix("/registries/proxies/gitlab").Handler( bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry))) return h diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go deleted file mode 100644 index 3f94bed4a..000000000 --- a/api/http/handler/registries/proxy.go +++ /dev/null @@ -1,83 +0,0 @@ -package registries - -import ( - "encoding/json" - "net/http" - "strconv" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" -) - -// request on /api/registries/:id/v2 -func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} - } - - err = handler.requestBouncer.RegistryAccess(r, registry) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} - } - - extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} - } - } - - managementConfiguration := registry.ManagementConfiguration - if managementConfiguration == nil { - managementConfiguration = createDefaultManagementConfiguration(registry) - } - - encodedConfiguration, err := json.Marshal(managementConfiguration) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} - } - - id := strconv.Itoa(int(registryID)) - r.Header.Set("X-RegistryManagement-Key", id) - r.Header.Set("X-RegistryManagement-URI", registry.URL) - r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) - r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) - - http.StripPrefix("/registries/"+id, proxy).ServeHTTP(w, r) - return nil -} - -func createDefaultManagementConfiguration(registry *portainer.Registry) *portainer.RegistryManagementConfiguration { - config := &portainer.RegistryManagementConfiguration{ - Type: registry.Type, - TLSConfig: portainer.TLSConfiguration{ - TLS: false, - }, - } - - if registry.Authentication { - config.Authentication = true - config.Username = registry.Username - config.Password = registry.Password - } - - return config -} diff --git a/api/http/handler/registries/proxy_management_gitlab.go b/api/http/handler/registries/proxy_management_gitlab.go deleted file mode 100644 index 28f1ead12..000000000 --- a/api/http/handler/registries/proxy_management_gitlab.go +++ /dev/null @@ -1,66 +0,0 @@ -package registries - -import ( - "encoding/json" - "net/http" - "strconv" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/portainer/api" -) - -// request on /api/registries/{id}/proxies/gitlab -func (handler *Handler) proxyRequestsToGitlabAPIWithRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} - } - - err = handler.requestBouncer.RegistryAccess(r, registry) - if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} - } - - extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} - } - } - - config := &portainer.RegistryManagementConfiguration{ - Type: portainer.GitlabRegistry, - Password: registry.Password, - } - - encodedConfiguration, err := json.Marshal(config) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} - } - - id := strconv.Itoa(int(registryID)) - r.Header.Set("X-RegistryManagement-Key", id+"-gitlab") - r.Header.Set("X-RegistryManagement-URI", registry.Gitlab.InstanceURL) - r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) - r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) - - http.StripPrefix("/registries/"+id+"/proxies/gitlab", proxy).ServeHTTP(w, r) - return nil -} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index c967b6996..16bc4a30f 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -1,6 +1,7 @@ package registries import ( + "errors" "net/http" "strconv" @@ -8,6 +9,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type registryConfigurePayload struct { @@ -28,7 +30,7 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { if useAuthentication { username, err := request.RetrieveMultiPartFormValue(r, "Username", false) if err != nil { - return portainer.Error("Invalid username") + return errors.New("Invalid username") } payload.Username = username @@ -45,19 +47,19 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error { if useTLS && !skipTLSVerify { cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") if err != nil { - return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + return errors.New("Invalid certificate file. Ensure that the file is uploaded correctly") } payload.TLSCertFile = cert key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") if err != nil { - return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + return errors.New("Invalid key file. Ensure that the file is uploaded correctly") } payload.TLSKeyFile = key ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") if err != nil { - return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + return errors.New("Invalid CA certificate file. Ensure that the file is uploaded correctly") } payload.TLSCACertFile = ca } @@ -78,8 +80,8 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} @@ -128,7 +130,7 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request } } - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} } diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 09f6d0a2e..fc0444e42 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -1,6 +1,7 @@ package registries import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -22,16 +23,16 @@ type registryCreatePayload struct { func (payload *registryCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid registry name") + return errors.New("Invalid registry name") } if govalidator.IsNull(payload.URL) { - return portainer.Error("Invalid registry URL") + return errors.New("Invalid registry URL") } if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { - return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled") } if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry { - return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)") + return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)") } return nil } @@ -55,7 +56,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * Gitlab: payload.Gitlab, } - err = handler.RegistryService.CreateRegistry(registry) + err = handler.DataStore.Registry().CreateRegistry(registry) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} } diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index ebb833ab4..877ca4a39 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/registries/:id @@ -16,14 +17,14 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} } - _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { + _, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } - err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) + err = handler.DataStore.Registry().DeleteRegistry(portainer.RegistryID(registryID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err} } diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index d40ef693a..715e7dcc0 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -3,6 +3,9 @@ package registries import ( "net/http" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -16,8 +19,8 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} } - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} @@ -25,7 +28,7 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) err = handler.requestBouncer.RegistryAccess(r, registry) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied} } hideFields(registry) diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go index b78763375..1acd380f4 100644 --- a/api/http/handler/registries/registry_list.go +++ b/api/http/handler/registries/registry_list.go @@ -10,7 +10,7 @@ import ( // GET request on /api/registries func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - registries, err := handler.RegistryService.Registries() + registries, err := handler.DataStore.Registry().Registries() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 15dd2d9dc..e77dfb765 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -1,12 +1,14 @@ package registries import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type registryUpdatePayload struct { @@ -36,8 +38,8 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrObjectNotFound { + registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} @@ -48,13 +50,13 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * } if payload.URL != nil { - registries, err := handler.RegistryService.Registries() + registries, err := handler.DataStore.Registry().Registries() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } for _, r := range registries { - if r.URL == *payload.URL && r.ID != registry.ID { - return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + if r.ID != registry.ID && hasSameURL(&r, registry) { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")} } } @@ -88,10 +90,18 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * registry.TeamAccessPolicies = payload.TeamAccessPolicies } - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} } return response.JSON(w, registry) } + +func hasSameURL(r1, r2 *portainer.Registry) bool { + if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { + return r1.URL == r2.URL + } + + return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath +} diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go index e6851c649..d0dc65b19 100644 --- a/api/http/handler/resourcecontrols/handler.go +++ b/api/http/handler/resourcecontrols/handler.go @@ -12,7 +12,7 @@ import ( // Handler is the HTTP handler used to handle resource control operations. type Handler struct { *mux.Router - ResourceControlService portainer.ResourceControlService + DataStore portainer.DataStore } // NewHandler creates a handler to manage resource control operations. diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index 0a6696bd9..0371c6f8c 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -21,6 +21,11 @@ type resourceControlCreatePayload struct { SubResourceIDs []string } +var ( + errResourceControlAlreadyExists = errors.New("A resource control is already applied on this resource") //http/resourceControl + errInvalidResourceControlType = errors.New("Unsupported resource control type") //http/resourceControl +) + func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.ResourceID) { return errors.New("invalid payload: invalid resource identifier") @@ -65,15 +70,15 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req case "config": resourceControlType = portainer.ConfigResourceControl default: - return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", portainer.ErrInvalidResourceControlType} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", errInvalidResourceControlType} } - rc, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(payload.ResourceID, resourceControlType) + rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, resourceControlType) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} } if rc != nil { - return &httperror.HandlerError{http.StatusConflict, "A resource control is already associated to this resource", portainer.ErrResourceControlAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A resource control is already associated to this resource", errResourceControlAlreadyExists} } var userAccesses = make([]portainer.UserResourceAccess, 0) @@ -104,7 +109,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req TeamAccesses: teamAccesses, } - err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + err = handler.DataStore.ResourceControl().CreateResourceControl(&resourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the resource control inside the database", err} } diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go index 76794e423..394974923 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_delete.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/resource_controls/:id @@ -16,14 +17,14 @@ func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} } - _, err = handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - if err == portainer.ErrObjectNotFound { + _, err = handler.DataStore.ResourceControl().ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} } - err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + err = handler.DataStore.ResourceControl().DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} } diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index fc170f1bd..b200a290d 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -8,6 +8,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -42,8 +44,8 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - if err == portainer.ErrObjectNotFound { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} @@ -55,7 +57,7 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req } if !security.AuthorizedResourceControlAccess(resourceControl, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access the resource control", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access the resource control", httperrors.ErrResourceAccessDenied} } resourceControl.Public = payload.Public @@ -82,10 +84,10 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req resourceControl.TeamAccesses = teamAccesses if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", httperrors.ErrResourceAccessDenied} } - err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + err = handler.DataStore.ResourceControl().UpdateResourceControl(resourceControl.ID, resourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} } diff --git a/api/http/handler/roles/handler.go b/api/http/handler/roles/handler.go index 89ec52452..a4a709562 100644 --- a/api/http/handler/roles/handler.go +++ b/api/http/handler/roles/handler.go @@ -12,7 +12,7 @@ import ( // Handler is the HTTP handler used to handle role operations. type Handler struct { *mux.Router - RoleService portainer.RoleService + DataStore portainer.DataStore } // NewHandler creates a handler to manage role operations. diff --git a/api/http/handler/roles/role_list.go b/api/http/handler/roles/role_list.go index e39e38595..11817c2f3 100644 --- a/api/http/handler/roles/role_list.go +++ b/api/http/handler/roles/role_list.go @@ -9,7 +9,7 @@ import ( // GET request on /api/Role func (handler *Handler) roleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - roles, err := handler.RoleService.Roles() + roles, err := handler.DataStore.Role().Roles() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve authorization sets from the database", err} } diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go deleted file mode 100644 index cc7d3dbf2..000000000 --- a/api/http/handler/schedules/handler.go +++ /dev/null @@ -1,45 +0,0 @@ -package schedules - -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 schedule operations. -type Handler struct { - *mux.Router - ScheduleService portainer.ScheduleService - EndpointService portainer.EndpointService - SettingsService portainer.SettingsService - FileService portainer.FileService - JobService portainer.JobService - JobScheduler portainer.JobScheduler - ReverseTunnelService portainer.ReverseTunnelService -} - -// NewHandler creates a handler to manage schedule operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { - h := &Handler{ - Router: mux.NewRouter(), - } - - h.Handle("/schedules", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) - h.Handle("/schedules", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) - h.Handle("/schedules/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) - h.Handle("/schedules/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) - h.Handle("/schedules/{id}", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete) - h.Handle("/schedules/{id}/file", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet) - h.Handle("/schedules/{id}/tasks", - bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet) - return h -} diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go deleted file mode 100644 index 196913a33..000000000 --- a/api/http/handler/schedules/schedule_create.go +++ /dev/null @@ -1,280 +0,0 @@ -package schedules - -import ( - "encoding/base64" - "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/cron" -) - -type scheduleCreateFromFilePayload struct { - Name string - Image string - CronExpression string - Recurring bool - Endpoints []portainer.EndpointID - File []byte - RetryCount int - RetryInterval int -} - -type scheduleCreateFromFileContentPayload struct { - Name string - CronExpression string - Recurring bool - Image string - Endpoints []portainer.EndpointID - FileContent string - RetryCount int - RetryInterval int -} - -func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error { - name, err := request.RetrieveMultiPartFormValue(r, "Name", false) - if err != nil { - return errors.New("Invalid schedule name") - } - - if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { - return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") - } - payload.Name = name - - image, err := request.RetrieveMultiPartFormValue(r, "Image", false) - if err != nil { - return errors.New("Invalid schedule image") - } - payload.Image = image - - cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false) - if err != nil { - return errors.New("Invalid cron expression") - } - payload.CronExpression = cronExpression - - var endpoints []portainer.EndpointID - err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false) - if err != nil { - return errors.New("Invalid endpoints") - } - payload.Endpoints = endpoints - - file, _, err := request.RetrieveMultiPartFormFile(r, "file") - if err != nil { - return portainer.Error("Invalid script file. Ensure that the file is uploaded correctly") - } - payload.File = file - - retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true) - payload.RetryCount = retryCount - - retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true) - payload.RetryInterval = retryInterval - - return nil -} - -func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid schedule name") - } - - if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { - return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") - } - - if govalidator.IsNull(payload.Image) { - return portainer.Error("Invalid schedule image") - } - - if govalidator.IsNull(payload.CronExpression) { - return portainer.Error("Invalid cron expression") - } - - if payload.Endpoints == nil || len(payload.Endpoints) == 0 { - return portainer.Error("Invalid endpoints payload") - } - - if govalidator.IsNull(payload.FileContent) { - return portainer.Error("Invalid script file content") - } - - if payload.RetryCount != 0 && payload.RetryInterval == 0 { - return portainer.Error("RetryInterval must be set") - } - - return nil -} - -// POST /api/schedules?method=file|string -func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - method, err := request.RetrieveQueryParameter(r, "method", false) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", err} - } - - switch method { - case "string": - return handler.createScheduleFromFileContent(w, r) - case "file": - return handler.createScheduleFromFile(w, r) - default: - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)} - } -} - -func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload scheduleCreateFromFileContentPayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - schedule := handler.createScheduleObjectFromFileContentPayload(&payload) - - err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err} - } - - return response.JSON(w, schedule) -} - -func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - payload := &scheduleCreateFromFilePayload{} - err := payload.Validate(r) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - schedule := handler.createScheduleObjectFromFilePayload(payload) - - err = handler.addAndPersistSchedule(schedule, payload.File) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err} - } - - return response.JSON(w, schedule) -} - -func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule { - scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) - - job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - RetryCount: payload.RetryCount, - RetryInterval: payload.RetryInterval, - } - - schedule := &portainer.Schedule{ - ID: scheduleIdentifier, - Name: payload.Name, - CronExpression: payload.CronExpression, - Recurring: payload.Recurring, - JobType: portainer.ScriptExecutionJobType, - ScriptExecutionJob: job, - Created: time.Now().Unix(), - } - - return schedule -} - -func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule { - scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) - - job := &portainer.ScriptExecutionJob{ - Endpoints: payload.Endpoints, - Image: payload.Image, - RetryCount: payload.RetryCount, - RetryInterval: payload.RetryInterval, - } - - schedule := &portainer.Schedule{ - ID: scheduleIdentifier, - Name: payload.Name, - CronExpression: payload.CronExpression, - Recurring: payload.Recurring, - JobType: portainer.ScriptExecutionJobType, - ScriptExecutionJob: job, - Created: time.Now().Unix(), - } - - return schedule -} - -func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error { - nonEdgeEndpointIDs := make([]portainer.EndpointID, 0) - edgeEndpointIDs := make([]portainer.EndpointID, 0) - - edgeCronExpression := strings.Split(schedule.CronExpression, " ") - if len(edgeCronExpression) == 6 { - edgeCronExpression = edgeCronExpression[1:] - } - - for _, ID := range schedule.ScriptExecutionJob.Endpoints { - - endpoint, err := handler.EndpointService.Endpoint(ID) - if err != nil { - return err - } - - if endpoint.Type != portainer.EdgeAgentEnvironment { - nonEdgeEndpointIDs = append(nonEdgeEndpointIDs, endpoint.ID) - } else { - edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID) - } - } - - if len(edgeEndpointIDs) > 0 { - edgeSchedule := &portainer.EdgeSchedule{ - ID: schedule.ID, - CronExpression: strings.Join(edgeCronExpression, " "), - Script: base64.RawStdEncoding.EncodeToString(file), - Endpoints: edgeEndpointIDs, - Version: 1, - } - - for _, endpointID := range edgeEndpointIDs { - handler.ReverseTunnelService.AddSchedule(endpointID, edgeSchedule) - } - - schedule.EdgeSchedule = edgeSchedule - } - - schedule.ScriptExecutionJob.Endpoints = nonEdgeEndpointIDs - - scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file) - if err != nil { - return err - } - - schedule.ScriptExecutionJob.ScriptPath = scriptPath - - jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) - - err = handler.JobScheduler.ScheduleJob(jobRunner) - if err != nil { - return err - } - - return handler.ScheduleService.CreateSchedule(schedule) -} diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go deleted file mode 100644 index c30b01696..000000000 --- a/api/http/handler/schedules/schedule_delete.go +++ /dev/null @@ -1,55 +0,0 @@ -package schedules - -import ( - "errors" - "net/http" - "strconv" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} - } - - schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} - } - - if schedule.JobType == portainer.SnapshotJobType || schedule.JobType == portainer.EndpointSyncJobType { - return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")} - } - - scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID)) - err = handler.FileService.RemoveDirectory(scheduleFolder) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err} - } - - handler.ReverseTunnelService.RemoveSchedule(schedule.ID) - - handler.JobScheduler.UnscheduleJob(schedule.ID) - - err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/schedules/schedule_file.go b/api/http/handler/schedules/schedule_file.go deleted file mode 100644 index c10b698dd..000000000 --- a/api/http/handler/schedules/schedule_file.go +++ /dev/null @@ -1,49 +0,0 @@ -package schedules - -import ( - "errors" - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -type scheduleFileResponse struct { - ScheduleFileContent string `json:"ScheduleFileContent"` -} - -// GET request on /api/schedules/:id/file -func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} - } - - schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} - } - - if schedule.JobType != portainer.ScriptExecutionJobType { - return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")} - } - - scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err} - } - - return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)}) -} diff --git a/api/http/handler/schedules/schedule_inspect.go b/api/http/handler/schedules/schedule_inspect.go deleted file mode 100644 index 594c29b1a..000000000 --- a/api/http/handler/schedules/schedule_inspect.go +++ /dev/null @@ -1,35 +0,0 @@ -package schedules - -import ( - "net/http" - - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" -) - -func (handler *Handler) scheduleInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} - } - - schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} - } - - return response.JSON(w, schedule) -} diff --git a/api/http/handler/schedules/schedule_list.go b/api/http/handler/schedules/schedule_list.go deleted file mode 100644 index 55662dc0e..000000000 --- a/api/http/handler/schedules/schedule_list.go +++ /dev/null @@ -1,27 +0,0 @@ -package schedules - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -// GET request on /api/schedules -func (handler *Handler) scheduleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - schedules, err := handler.ScheduleService.Schedules() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedules from the database", err} - } - - return response.JSON(w, schedules) -} diff --git a/api/http/handler/schedules/schedule_tasks.go b/api/http/handler/schedules/schedule_tasks.go deleted file mode 100644 index a4993e6cd..000000000 --- a/api/http/handler/schedules/schedule_tasks.go +++ /dev/null @@ -1,114 +0,0 @@ -package schedules - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -type taskContainer struct { - ID string `json:"Id"` - EndpointID portainer.EndpointID `json:"EndpointId"` - Status string `json:"Status"` - Created float64 `json:"Created"` - Labels map[string]string `json:"Labels"` - Edge bool `json:"Edge"` -} - -// GET request on /api/schedules/:id/tasks -func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} - } - - schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} - } - - if schedule.JobType != portainer.ScriptExecutionJobType { - return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve schedule tasks", errors.New("This type of schedule do not have any associated tasks")} - } - - tasks := make([]taskContainer, 0) - - for _, endpointID := range schedule.ScriptExecutionJob.Endpoints { - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrObjectNotFound { - continue - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} - } - - endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err} - } - - tasks = append(tasks, endpointTasks...) - } - - if schedule.EdgeSchedule != nil { - for _, endpointID := range schedule.EdgeSchedule.Endpoints { - - cronTask := taskContainer{ - ID: fmt.Sprintf("schedule_%d", schedule.EdgeSchedule.ID), - EndpointID: endpointID, - Edge: true, - Status: "", - Created: 0, - Labels: map[string]string{}, - } - - tasks = append(tasks, cronTask) - } - } - - return response.JSON(w, tasks) -} - -func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) { - endpointTasks := make([]taskContainer, 0) - if len(endpoint.Snapshots) == 0 { - return endpointTasks, nil - } - - b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers) - if err != nil { - return nil, err - } - - var containers []taskContainer - err = json.Unmarshal(b, &containers) - if err != nil { - return nil, err - } - - for _, container := range containers { - if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) { - container.EndpointID = endpoint.ID - container.Edge = false - endpointTasks = append(endpointTasks, container) - } - } - - return endpointTasks, nil -} diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go deleted file mode 100644 index f68e77126..000000000 --- a/api/http/handler/schedules/schedule_update.go +++ /dev/null @@ -1,175 +0,0 @@ -package schedules - -import ( - "encoding/base64" - "errors" - "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" - "github.com/portainer/portainer/api/cron" -) - -type scheduleUpdatePayload struct { - Name *string - Image *string - CronExpression *string - Recurring *bool - Endpoints []portainer.EndpointID - FileContent *string - RetryCount *int - RetryInterval *int -} - -func (payload *scheduleUpdatePayload) Validate(r *http.Request) error { - if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { - return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]") - } - return nil -} - -func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} - } - if !settings.EnableHostManagementFeatures { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} - } - - scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} - } - - var payload scheduleUpdatePayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} - } - - updateJobSchedule := false - if schedule.EdgeSchedule != nil { - err := handler.updateEdgeSchedule(schedule, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge schedule", err} - } - } else { - updateJobSchedule = updateSchedule(schedule, &payload) - } - - if payload.FileContent != nil { - _, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err} - } - updateJobSchedule = true - } - - if updateJobSchedule { - jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) - jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) - err := handler.JobScheduler.UpdateJobSchedule(jobRunner) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err} - } - } - - err = handler.ScheduleService.UpdateSchedule(portainer.ScheduleID(scheduleID), schedule) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist schedule changes inside the database", err} - } - - return response.JSON(w, schedule) -} - -func (handler *Handler) updateEdgeSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) error { - if payload.Name != nil { - schedule.Name = *payload.Name - } - - if payload.Endpoints != nil { - - edgeEndpointIDs := make([]portainer.EndpointID, 0) - - for _, ID := range payload.Endpoints { - endpoint, err := handler.EndpointService.Endpoint(ID) - if err != nil { - return err - } - - if endpoint.Type == portainer.EdgeAgentEnvironment { - edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID) - } - } - - schedule.EdgeSchedule.Endpoints = edgeEndpointIDs - } - - if payload.CronExpression != nil { - schedule.EdgeSchedule.CronExpression = *payload.CronExpression - schedule.EdgeSchedule.Version++ - } - - if payload.FileContent != nil { - schedule.EdgeSchedule.Script = base64.RawStdEncoding.EncodeToString([]byte(*payload.FileContent)) - schedule.EdgeSchedule.Version++ - } - - for _, endpointID := range schedule.EdgeSchedule.Endpoints { - handler.ReverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule) - } - - return nil -} - -func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool { - updateJobSchedule := false - - if payload.Name != nil { - schedule.Name = *payload.Name - } - - if payload.Endpoints != nil { - schedule.ScriptExecutionJob.Endpoints = payload.Endpoints - updateJobSchedule = true - } - - if payload.CronExpression != nil { - schedule.CronExpression = *payload.CronExpression - updateJobSchedule = true - } - - if payload.Recurring != nil { - schedule.Recurring = *payload.Recurring - updateJobSchedule = true - } - - if payload.Image != nil { - schedule.ScriptExecutionJob.Image = *payload.Image - updateJobSchedule = true - } - - if payload.RetryCount != nil { - schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount - updateJobSchedule = true - } - - if payload.RetryInterval != nil { - schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval - updateJobSchedule = true - } - - return updateJobSchedule -} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 1f688f343..9fa17b842 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -17,14 +17,11 @@ func hideFields(settings *portainer.Settings) { // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService - JobScheduler portainer.JobScheduler - ScheduleService portainer.ScheduleService - RoleService portainer.RoleService - ExtensionService portainer.ExtensionService - AuthorizationService *portainer.AuthorizationService + DataStore portainer.DataStore + FileService portainer.FileService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SnapshotService portainer.SnapshotService } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index a28b1ef09..0e732ee43 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -9,7 +9,7 @@ import ( // GET request on /api/settings func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index e70125afe..e94f501e0 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -6,37 +6,45 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) type publicSettingsResponse struct { - LogoURL string `json:"LogoURL"` - AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` - AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` - AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` - AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` - EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` - EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` - ExternalTemplates bool `json:"ExternalTemplates"` - OAuthLoginURI string `json:"OAuthLoginURI"` + LogoURL string `json:"LogoURL"` + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"` + AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"` + AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"` + AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"` + AllowContainerCapabilitiesForRegularUsers bool `json:"AllowContainerCapabilitiesForRegularUsers"` + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` + OAuthLoginURI string `json:"OAuthLoginURI"` + EnableTelemetry bool `json:"EnableTelemetry"` } // GET request on /api/settings/public func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } publicSettings := &publicSettingsResponse{ - LogoURL: settings.LogoURL, - AuthenticationMethod: settings.AuthenticationMethod, - AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, - AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, - EnableHostManagementFeatures: settings.EnableHostManagementFeatures, - EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, - ExternalTemplates: false, + LogoURL: settings.LogoURL, + AuthenticationMethod: settings.AuthenticationMethod, + AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers, + AllowHostNamespaceForRegularUsers: settings.AllowHostNamespaceForRegularUsers, + AllowDeviceMappingForRegularUsers: settings.AllowDeviceMappingForRegularUsers, + AllowStackManagementForRegularUsers: settings.AllowStackManagementForRegularUsers, + AllowContainerCapabilitiesForRegularUsers: settings.AllowContainerCapabilitiesForRegularUsers, + EnableHostManagementFeatures: settings.EnableHostManagementFeatures, + EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, + EnableTelemetry: settings.EnableTelemetry, OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", settings.OAuthSettings.AuthorizationURI, settings.OAuthSettings.ClientID, @@ -44,9 +52,5 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * settings.OAuthSettings.Scopes), } - if settings.TemplatesURL != "" { - publicSettings.ExternalTemplates = true - } - return response.JSON(w, publicSettings) } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index cef3bdf0f..fbdf47bcf 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -1,42 +1,57 @@ package settings import ( + "errors" "net/http" + "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" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" ) type settingsUpdatePayload struct { - LogoURL *string - BlackListedLabels []portainer.Pair - AuthenticationMethod *int - LDAPSettings *portainer.LDAPSettings - OAuthSettings *portainer.OAuthSettings - AllowBindMountsForRegularUsers *bool - AllowPrivilegedModeForRegularUsers *bool - AllowVolumeBrowserForRegularUsers *bool - EnableHostManagementFeatures *bool - SnapshotInterval *string - TemplatesURL *string - EdgeAgentCheckinInterval *int - EnableEdgeComputeFeatures *bool + LogoURL *string + BlackListedLabels []portainer.Pair + AuthenticationMethod *int + LDAPSettings *portainer.LDAPSettings + OAuthSettings *portainer.OAuthSettings + AllowBindMountsForRegularUsers *bool + AllowPrivilegedModeForRegularUsers *bool + AllowHostNamespaceForRegularUsers *bool + AllowVolumeBrowserForRegularUsers *bool + AllowDeviceMappingForRegularUsers *bool + AllowStackManagementForRegularUsers *bool + AllowContainerCapabilitiesForRegularUsers *bool + EnableHostManagementFeatures *bool + SnapshotInterval *string + TemplatesURL *string + EdgeAgentCheckinInterval *int + EnableEdgeComputeFeatures *bool + UserSessionTimeout *string + EnableTelemetry *bool } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { - return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)") + if payload.AuthenticationMethod != nil && *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { + return errors.New("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)") } if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { - return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") + return errors.New("Invalid logo URL. Must correspond to a valid URL format") } if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) { - return portainer.Error("Invalid external templates URL. Must correspond to a valid URL format") + return errors.New("Invalid external templates URL. Must correspond to a valid URL format") } + if payload.UserSessionTimeout != nil { + _, err := time.ParseDuration(*payload.UserSessionTimeout) + if err != nil { + return errors.New("Invalid user session timeout") + } + } + return nil } @@ -48,7 +63,7 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} } @@ -100,10 +115,8 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers } - updateAuthorizations := false if payload.AllowVolumeBrowserForRegularUsers != nil { settings.AllowVolumeBrowserForRegularUsers = *payload.AllowVolumeBrowserForRegularUsers - updateAuthorizations = true } if payload.EnableHostManagementFeatures != nil { @@ -114,6 +127,18 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures } + if payload.AllowHostNamespaceForRegularUsers != nil { + settings.AllowHostNamespaceForRegularUsers = *payload.AllowHostNamespaceForRegularUsers + } + + if payload.AllowStackManagementForRegularUsers != nil { + settings.AllowStackManagementForRegularUsers = *payload.AllowStackManagementForRegularUsers + } + + if payload.AllowContainerCapabilitiesForRegularUsers != nil { + settings.AllowContainerCapabilitiesForRegularUsers = *payload.AllowContainerCapabilitiesForRegularUsers + } + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) if err != nil { @@ -125,70 +150,43 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval } + if payload.UserSessionTimeout != nil { + settings.UserSessionTimeout = *payload.UserSessionTimeout + + userSessionDuration, _ := time.ParseDuration(*payload.UserSessionTimeout) + + handler.JWTService.SetUserSessionDuration(userSessionDuration) + } + + if payload.AllowDeviceMappingForRegularUsers != nil { + settings.AllowDeviceMappingForRegularUsers = *payload.AllowDeviceMappingForRegularUsers + } + + if payload.EnableTelemetry != nil { + settings.EnableTelemetry = *payload.EnableTelemetry + } + tlsError := handler.updateTLS(settings) if tlsError != nil { return tlsError } - err = handler.SettingsService.UpdateSettings(settings) + err = handler.DataStore.Settings().UpdateSettings(settings) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } - if updateAuthorizations { - err := handler.updateVolumeBrowserSetting(settings) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update RBAC authorizations", err} - } - } - return response.JSON(w, settings) } -func (handler *Handler) updateVolumeBrowserSetting(settings *portainer.Settings) error { - err := handler.AuthorizationService.UpdateVolumeBrowsingAuthorizations(settings.AllowVolumeBrowserForRegularUsers) - if err != nil { - return err - } - - extension, err := handler.ExtensionService.Extension(portainer.RBACExtension) - if err != nil && err != portainer.ErrObjectNotFound { - return err - } - - if extension != nil { - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return err - } - } - - return nil -} - func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { settings.SnapshotInterval = snapshotInterval - schedules, err := handler.ScheduleService.SchedulesByJobType(portainer.SnapshotJobType) + err := handler.SnapshotService.SetSnapshotInterval(snapshotInterval) if err != nil { return err } - if len(schedules) != 0 { - snapshotSchedule := schedules[0] - snapshotSchedule.CronExpression = "@every " + snapshotInterval - - err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression) - if err != nil { - return err - } - - err = handler.ScheduleService.UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule) - if err != nil { - return err - } - } - return nil } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index ebdcea252..6ceab991c 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -31,11 +31,11 @@ type composeStackFromFileContentPayload struct { func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } payload.Name = normalizeStackName(payload.Name) if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } return nil } @@ -47,18 +47,18 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -66,6 +66,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -88,7 +89,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -110,14 +111,14 @@ type composeStackFromGitRepositoryPayload struct { func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } payload.Name = normalizeStackName(payload.Name) if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { - return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + return errors.New("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") + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") } if govalidator.IsNull(payload.ComposeFilePathInRepository) { payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName @@ -132,18 +133,18 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -151,6 +152,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite EndpointID: endpoint.ID, EntryPoint: payload.ComposeFilePathInRepository, Env: payload.Env, + Status: portainer.StackStatusActive, } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -183,7 +185,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -201,20 +203,20 @@ type composeStackFromFileUploadPayload struct { func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } payload.Name = normalizeStackName(name) composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") if err != nil { - return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") } payload.StackFileContent = composeFileContent var env []portainer.Pair err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) if err != nil { - return portainer.Error("Invalid Env parameter") + return errors.New("Invalid Env parameter") } payload.Env = env return nil @@ -227,18 +229,18 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -246,6 +248,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -268,7 +271,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -283,6 +286,7 @@ type composeStackDeploymentConfig struct { dockerhub *portainer.DockerHub registries []portainer.Registry isAdmin bool + user *portainer.User } func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { @@ -291,23 +295,29 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - dockerhub, err := handler.DockerHubService.DockerHub() + dockerhub, err := handler.DataStore.DockerHub().DockerHub() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} } - registries, err := handler.RegistryService.Registries() + registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } filteredRegistries := security.FilterRegistries(registries, securityContext) + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} + } + config := &composeStackDeploymentConfig{ stack: stack, endpoint: endpoint, dockerhub: dockerhub, registries: filteredRegistries, isAdmin: securityContext.IsAdmin, + user: user, } return config, nil @@ -319,12 +329,23 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai // clean it. Hence the use of the mutex. // We should contribute to libcompose to support authentication without using the config.json file. func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return err } - if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) + if err != nil { + return err + } + + if (!settings.AllowBindMountsForRegularUsers || + !settings.AllowPrivilegedModeForRegularUsers || + !settings.AllowHostNamespaceForRegularUsers || + !settings.AllowDeviceMappingForRegularUsers || + !settings.AllowContainerCapabilitiesForRegularUsers) && + !isAdminOrEndpointAdmin { + composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) stackContent, err := handler.FileService.GetFileContent(composeFilePath) @@ -332,13 +353,10 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) return err } - valid, err := handler.isValidStackFile(stackContent) + err = handler.isValidStackFile(stackContent, settings) if err != nil { return err } - if !valid { - return errors.New("bind-mount disabled for non administrator users") - } } handler.stackCreationMutex.Lock() diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go new file mode 100644 index 000000000..61ca665a5 --- /dev/null +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -0,0 +1,59 @@ +package stacks + +import ( + "errors" + "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 kubernetesStackPayload struct { + ComposeFormat bool + Namespace string + StackFileContent string +} + +func (payload *kubernetesStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return errors.New("Invalid stack file content") + } + if govalidator.IsNull(payload.Namespace) { + return errors.New("Invalid namespace") + } + return nil +} + +type createKubernetesStackResponse struct { + Output string `json:"Output"` +} + +func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload kubernetesStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to deploy Kubernetes stack", err} + } + + resp := &createKubernetesStackResponse{ + Output: string(output), + } + + return response.JSON(w, resp) +} + +func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + return handler.KubernetesDeployer.Deploy(endpoint, data, composeFormat, namespace) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 143292ea9..0113e8a41 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -24,13 +24,13 @@ type swarmStackFromFileContentPayload struct { func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } if govalidator.IsNull(payload.SwarmID) { - return portainer.Error("Invalid Swarm ID") + return errors.New("Invalid Swarm ID") } if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } return nil } @@ -42,18 +42,18 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -62,6 +62,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -84,7 +85,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -107,16 +108,16 @@ type swarmStackFromGitRepositoryPayload struct { func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } if govalidator.IsNull(payload.SwarmID) { - return portainer.Error("Invalid Swarm ID") + return errors.New("Invalid Swarm ID") } if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { - return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + return errors.New("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") + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") } if govalidator.IsNull(payload.ComposeFilePathInRepository) { payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName @@ -131,18 +132,18 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -151,6 +152,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, EndpointID: endpoint.ID, EntryPoint: payload.ComposeFilePathInRepository, Env: payload.Env, + Status: portainer.StackStatusActive, } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -183,7 +185,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -202,26 +204,26 @@ type swarmStackFromFileUploadPayload struct { func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return portainer.Error("Invalid stack name") + return errors.New("Invalid stack name") } payload.Name = name swarmID, err := request.RetrieveMultiPartFormValue(r, "SwarmID", false) if err != nil { - return portainer.Error("Invalid Swarm ID") + return errors.New("Invalid Swarm ID") } payload.SwarmID = swarmID composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") if err != nil { - return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") } payload.StackFileContent = composeFileContent var env []portainer.Pair err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) if err != nil { - return portainer.Error("Invalid Env parameter") + return errors.New("Invalid Env parameter") } payload.Env = env return nil @@ -234,18 +236,18 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } for _, stack := range stacks { if strings.EqualFold(stack.Name, payload.Name) { - return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", errStackAlreadyExists} } } - stackID := handler.StackService.GetNextIdentifier() + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: payload.Name, @@ -254,6 +256,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r EndpointID: endpoint.ID, EntryPoint: filesystem.ComposeFileDefaultName, Env: payload.Env, + Status: portainer.StackStatusActive, } stackFolder := strconv.Itoa(int(stack.ID)) @@ -276,7 +279,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.CreateStack(stack) + err = handler.DataStore.Stack().CreateStack(stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} } @@ -292,6 +295,7 @@ type swarmStackDeploymentConfig struct { registries []portainer.Registry prune bool isAdmin bool + user *portainer.User } func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { @@ -300,17 +304,22 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - dockerhub, err := handler.DockerHubService.DockerHub() + dockerhub, err := handler.DataStore.DockerHub().DockerHub() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} } - registries, err := handler.RegistryService.Registries() + registries, err := handler.DataStore.Registry().Registries() if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} } filteredRegistries := security.FilterRegistries(registries, securityContext) + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} + } + config := &swarmStackDeploymentConfig{ stack: stack, endpoint: endpoint, @@ -318,18 +327,24 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine registries: filteredRegistries, prune: prune, isAdmin: securityContext.IsAdmin, + user: user, } return config, nil } func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return err } - if !settings.AllowBindMountsForRegularUsers && !config.isAdmin { + isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) + if err != nil { + return err + } + + if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin { composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) stackContent, err := handler.FileService.GetFileContent(composeFilePath) @@ -337,13 +352,10 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err return err } - valid, err := handler.isValidStackFile(stackContent) + err = handler.isValidStackFile(stackContent, settings) if err != nil { return err } - if !valid { - return errors.New("bind-mount disabled for non administrator users") - } } handler.stackCreationMutex.Lock() diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index d0a6b4ea5..c706afbfd 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -1,6 +1,7 @@ package stacks import ( + "errors" "net/http" "sync" @@ -8,6 +9,12 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +var ( + errStackAlreadyExists = errors.New("A stack already exists with this name") + errStackNotExternal = errors.New("Not an external stack") ) // Handler is the HTTP handler used to handle stack operations. @@ -16,18 +23,12 @@ type Handler struct { stackDeletionMutex *sync.Mutex requestBouncer *security.RequestBouncer *mux.Router - FileService portainer.FileService - GitService portainer.GitService - StackService portainer.StackService - EndpointService portainer.EndpointService - ResourceControlService portainer.ResourceControlService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - SwarmStackManager portainer.SwarmStackManager - ComposeStackManager portainer.ComposeStackManager - SettingsService portainer.SettingsService - UserService portainer.UserService - ExtensionService portainer.ExtensionService + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager + KubernetesDeployer portainer.KubernetesDeployer } // NewHandler creates a handler to manage stack operations. @@ -52,12 +53,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/start", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStart))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/stop", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStop))).Methods(http.MethodPost) return h } func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID, resourceControl *portainer.ResourceControl) (bool, error) { - if securityContext.IsAdmin { - return true, nil + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return false, err } userTeamIDs := make([]portainer.TeamID, 0) @@ -65,25 +71,24 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR userTeamIDs = append(userTeamIDs, membership.TeamID) } - if resourceControl != nil && portainer.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { + if resourceControl != nil && authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { return true, nil } - _, err := handler.ExtensionService.Extension(portainer.RBACExtension) - if err == portainer.ErrObjectNotFound { - return false, nil - } else if err != nil && err != portainer.ErrObjectNotFound { - return false, err - } + return handler.userIsAdminOrEndpointAdmin(user, endpointID) +} - user, err := handler.UserService.User(securityContext.UserID) +func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { + isAdmin := user.Role == portainer.AdministratorRole + + return isAdmin, nil +} + +func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) { + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { return false, err } - _, ok := user.EndpointAuthorizations[endpointID][portainer.EndpointResourcesAccess] - if ok { - return true, nil - } - return false, nil + return handler.userIsAdminOrEndpointAdmin(user, endpointID) } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index dc374c4b1..c9f115ad1 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -10,8 +10,11 @@ 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" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { @@ -43,14 +46,37 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if !settings.AllowStackManagementForRegularUsers { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} + } + + canCreate, err := handler.userCanCreateStack(securityContext, portainer.EndpointID(endpointID)) + + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack creation", err} + } + + if !canCreate { + errMsg := "Stack creation is disabled for non-admin users" + return &httperror.HandlerError{http.StatusForbidden, errMsg, errors.New(errMsg)} + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -65,6 +91,12 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return handler.createSwarmStack(w, r, method, endpoint, tokenData.ID) case portainer.DockerComposeStack: return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) + case portainer.KubernetesStack: + if tokenData.Role != portainer.AdministratorRole { + return &httperror.HandlerError{http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized} + } + + return handler.createKubernetesStack(w, r, endpoint) } return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)} @@ -97,10 +129,10 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} } -func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) { +func (handler *Handler) isValidStackFile(stackFileContent []byte, settings *portainer.Settings) error { composeConfigYAML, err := loader.ParseYAML(stackFileContent) if err != nil { - return false, err + return err } composeConfigFile := types.ConfigFile{ @@ -117,25 +149,43 @@ func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) options.SkipInterpolation = true }) if err != nil { - return false, err + return err } for key := range composeConfig.Services { service := composeConfig.Services[key] - for _, volume := range service.Volumes { - if volume.Type == "bind" { - return false, nil + if !settings.AllowBindMountsForRegularUsers { + for _, volume := range service.Volumes { + if volume.Type == "bind" { + return errors.New("bind-mount disabled for non administrator users") + } } } + + if !settings.AllowPrivilegedModeForRegularUsers && service.Privileged == true { + return errors.New("privileged mode disabled for non administrator users") + } + + if !settings.AllowHostNamespaceForRegularUsers && service.Pid == "host" { + return errors.New("pid host disabled for non administrator users") + } + + if !settings.AllowDeviceMappingForRegularUsers && service.Devices != nil && len(service.Devices) > 0 { + return errors.New("device mapping disabled for non administrator users") + } + + if !settings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) { + return errors.New("container capabilities disabled for non administrator users") + } } - return true, nil + return nil } func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError { - resourceControl := portainer.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID) + resourceControl := authorization.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID) - err := handler.ResourceControlService.CreateResourceControl(resourceControl) + err := handler.DataStore.ResourceControl().CreateResourceControl(resourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err} } diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index da7e82751..6866c6809 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -1,15 +1,17 @@ package stacks import ( + "errors" "net/http" "strconv" - "github.com/portainer/portainer/api/http/security" - httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) // DELETE request on /api/stacks/:id?external=&endpointId= @@ -36,8 +38,8 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.StackService.Stack(portainer.StackID(id)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(id)) + if err == bolterrors.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} @@ -56,19 +58,19 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt endpointIdentifier = portainer.EndpointID(endpointID) } - endpoint, err := handler.EndpointService.Endpoint(endpointIdentifier) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointIdentifier) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -78,7 +80,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} } err = handler.deleteStack(stack, endpoint) @@ -86,13 +88,13 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.DeleteStack(portainer.StackID(id)) + err = handler.DataStore.Stack().DeleteStack(portainer.StackID(id)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} } if resourceControl != nil { - err = handler.ResourceControlService.DeleteResourceControl(resourceControl.ID) + err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} } @@ -112,48 +114,26 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit 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} + if !securityContext.IsAdmin { + return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", httperrors.ErrUnauthorized} } - 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 { + stack, err := handler.DataStore.Stack().StackByName(stackName) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} } if stack != nil { - return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", portainer.ErrStackNotExternal} + return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", errors.New("A tag already exists with this name")} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 7c966a08e..8024a72f2 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -8,6 +8,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -22,26 +24,26 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} } - endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -56,7 +58,7 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} } stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 42d0ad9c5..df9bc279a 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -17,21 +19,21 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} } - endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -41,7 +43,7 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -51,7 +53,7 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} } if resourceControl != nil { diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index 2c87cb40b..f8be720da 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) type stackListOperationFilters struct { @@ -23,13 +24,13 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} } - stacks, err := handler.StackService.Stacks() + stacks, err := handler.DataStore.Stack().Stacks() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} } stacks = filterStacks(stacks, &filters) - resourceControls, err := handler.ResourceControlService.ResourceControls() + resourceControls, err := handler.DataStore.ResourceControl().ResourceControls() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} } @@ -39,18 +40,10 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} } - stacks = portainer.DecorateStacks(stacks, resourceControls) + stacks = authorization.DecorateStacks(stacks, resourceControls) if !securityContext.IsAdmin { - rbacExtensionEnabled := true - _, err := handler.ExtensionService.Extension(portainer.RBACExtension) - if err == portainer.ErrObjectNotFound { - rbacExtensionEnabled = false - } else if err != nil && err != portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check if RBAC extension is enabled", err} - } - - user, err := handler.UserService.User(securityContext.UserID) + user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err} } @@ -60,7 +53,7 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe userTeamIDs = append(userTeamIDs, membership.TeamID) } - stacks = portainer.FilterAuthorizedStacks(stacks, user, userTeamIDs, rbacExtensionEnabled) + stacks = authorization.FilterAuthorizedStacks(stacks, user, userTeamIDs) } return response.JSON(w, stacks) diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 690622a7a..e84f7e5f7 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -1,12 +1,15 @@ package stacks import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -18,7 +21,7 @@ type stackMigratePayload struct { func (payload *stackMigratePayload) Validate(r *http.Request) error { if payload.EndpointID == 0 { - return portainer.Error("Invalid endpoint identifier. Must be a positive number") + return errors.New("Invalid endpoint identifier. Must be a positive number") } return nil } @@ -36,26 +39,26 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} } - endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -70,7 +73,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 @@ -84,8 +87,8 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht stack.EndpointID = portainer.EndpointID(endpointID) } - targetEndpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(payload.EndpointID)) - if err == portainer.ErrObjectNotFound { + targetEndpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(payload.EndpointID)) + if err == bolterrors.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} @@ -112,7 +115,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.UpdateStack(stack.ID, stack) + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go new file mode 100644 index 000000000..298a11d42 --- /dev/null +++ b/api/http/handler/stacks/stack_start.go @@ -0,0 +1,87 @@ +package stacks + +import ( + "errors" + "net/http" + + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// POST request on /api/stacks/:id/start +func (handler *Handler) stackStart(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} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", 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} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + if stack.Status == portainer.StackStatusActive { + return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")} + } + + err = handler.startStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err} + } + + stack.Status = portainer.StackStatusActive + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + switch stack.Type { + case portainer.DockerComposeStack: + return handler.ComposeStackManager.Up(stack, endpoint) + case portainer.DockerSwarmStack: + return handler.SwarmStackManager.Deploy(stack, true, endpoint) + } + return nil +} diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go new file mode 100644 index 000000000..ee2c13f32 --- /dev/null +++ b/api/http/handler/stacks/stack_stop.go @@ -0,0 +1,88 @@ +package stacks + +import ( + "errors" + "net/http" + + httperrors "github.com/portainer/portainer/api/http/errors" + + "github.com/portainer/portainer/api/http/security" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// POST request on /api/stacks/:id/stop +func (handler *Handler) stackStop(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} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.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.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", 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} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } + + if stack.Status == portainer.StackStatusInactive { + return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")} + } + + err = handler.stopStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err} + } + + stack.Status = portainer.StackStatusInactive + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + switch stack.Type { + case portainer.DockerComposeStack: + return handler.ComposeStackManager.Down(stack, endpoint) + case portainer.DockerSwarmStack: + return handler.SwarmStackManager.Remove(stack, endpoint) + } + return nil +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 1e4e08fdf..df4178f8f 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -1,16 +1,18 @@ package stacks import ( + "errors" "net/http" "strconv" - "github.com/portainer/portainer/api/http/security" - "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" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) type updateComposeStackPayload struct { @@ -20,7 +22,7 @@ type updateComposeStackPayload struct { func (payload *updateComposeStackPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } return nil } @@ -33,7 +35,7 @@ type updateSwarmStackPayload struct { func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { - return portainer.Error("Invalid stack file content") + return errors.New("Invalid stack file content") } return nil } @@ -45,8 +47,8 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrObjectNotFound { + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.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} @@ -63,19 +65,19 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt stack.EndpointID = portainer.EndpointID(endpointID) } - endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -90,7 +92,7 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} } if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} } updateError := handler.updateAndDeployStack(r, stack, endpoint) @@ -98,7 +100,7 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return updateError } - err = handler.StackService.UpdateStack(stack.ID, stack) + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } diff --git a/api/http/handler/support/handler.go b/api/http/handler/support/handler.go deleted file mode 100644 index 1ac8de22a..000000000 --- a/api/http/handler/support/handler.go +++ /dev/null @@ -1,26 +0,0 @@ -package support - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - - "github.com/gorilla/mux" - "github.com/portainer/portainer/api/http/security" -) - -// Handler is the HTTP handler used to handle support operations. -type Handler struct { - *mux.Router -} - -// NewHandler returns a new Handler -func NewHandler(bouncer *security.RequestBouncer) *Handler { - h := &Handler{ - Router: mux.NewRouter(), - } - h.Handle("/support", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.supportList))).Methods(http.MethodGet) - - return h -} diff --git a/api/http/handler/support/support_list.go b/api/http/handler/support/support_list.go deleted file mode 100644 index a16d8dafd..000000000 --- a/api/http/handler/support/support_list.go +++ /dev/null @@ -1,39 +0,0 @@ -package support - -import ( - "encoding/json" - - httperror "github.com/portainer/libhttp/error" - portainer "github.com/portainer/portainer/api" - - "net/http" - - "github.com/portainer/portainer/api/http/client" - - "github.com/portainer/libhttp/response" -) - -type supportProduct struct { - ID int `json:"Id"` - Name string `json:"Name"` - ShortDescription string `json:"ShortDescription"` - Price string `json:"Price"` - PriceDescription string `json:"PriceDescription"` - Description string `json:"Description"` - ProductID string `json:"ProductId"` -} - -func (handler *Handler) supportList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - supportData, err := client.Get(portainer.SupportProductsURL, 30) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch support options", err} - } - - var supportProducts []supportProduct - err = json.Unmarshal(supportData, &supportProducts) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch support options", err} - } - - return response.JSON(w, supportProducts) -} diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go index 21ca61acb..f7b6fd75d 100644 --- a/api/http/handler/tags/handler.go +++ b/api/http/handler/tags/handler.go @@ -12,12 +12,7 @@ import ( // Handler is the HTTP handler used to handle tag operations. type Handler struct { *mux.Router - TagService portainer.TagService - EdgeGroupService portainer.EdgeGroupService - EdgeStackService portainer.EdgeStackService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - EndpointRelationService portainer.EndpointRelationService + DataStore portainer.DataStore } // NewHandler creates a handler to manage tag operations. diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go index 846d256ee..5a2d1e400 100644 --- a/api/http/handler/tags/tag_create.go +++ b/api/http/handler/tags/tag_create.go @@ -1,6 +1,7 @@ package tags import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -16,7 +17,7 @@ type tagCreatePayload struct { func (payload *tagCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid tag name") + return errors.New("Invalid tag name") } return nil } @@ -29,14 +30,14 @@ func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - tags, err := handler.TagService.Tags() + tags, err := handler.DataStore.Tag().Tags() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} } for _, tag := range tags { if tag.Name == payload.Name { - return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", portainer.ErrTagAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", errors.New("A tag already exists with this name")} } } @@ -46,7 +47,7 @@ func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httpe Endpoints: map[portainer.EndpointID]bool{}, } - err = handler.TagService.CreateTag(tag) + err = handler.DataStore.Tag().CreateTag(tag) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err} } diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index c2cafe43e..287af24bd 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/edge" ) // DELETE request on /api/tags/:id @@ -17,15 +19,15 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe } tagID := portainer.TagID(id) - tag, err := handler.TagService.Tag(tagID) - if err == portainer.ErrObjectNotFound { + tag, err := handler.DataStore.Tag().Tag(tagID) + if err == errors.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) + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} } @@ -33,7 +35,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe tagIdx := findTagIndex(endpoint.TagIDs, tagID) if tagIdx != -1 { endpoint.TagIDs = removeElement(endpoint.TagIDs, tagIdx) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} } @@ -41,7 +43,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe } for endpointGroupID := range tag.EndpointGroups { - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpointGroupID) + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpointGroupID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint group from the database", err} } @@ -49,30 +51,30 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe tagIdx := findTagIndex(endpointGroup.TagIDs, tagID) if tagIdx != -1 { endpointGroup.TagIDs = removeElement(endpointGroup.TagIDs, tagIdx) - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} } } } - endpoints, err := handler.EndpointService.Endpoints() + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } - edgeGroups, err := handler.EdgeGroupService.EdgeGroups() + edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from the database", err} } - edgeStacks, err := handler.EdgeStackService.EdgeStacks() + edgeStacks, err := handler.DataStore.EdgeStack().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 { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { err = handler.updateEndpointRelations(endpoint, edgeGroups, edgeStacks) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint relations in the database", err} @@ -85,14 +87,14 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe tagIdx := findTagIndex(edgeGroup.TagIDs, tagID) if tagIdx != -1 { edgeGroup.TagIDs = removeElement(edgeGroup.TagIDs, tagIdx) - err = handler.EdgeGroupService.UpdateEdgeGroup(edgeGroup.ID, edgeGroup) + err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} } } } - err = handler.TagService.DeleteTag(tagID) + err = handler.DataStore.Tag().DeleteTag(tagID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} } @@ -101,24 +103,24 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe } func (handler *Handler) updateEndpointRelations(endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { - endpointRelation, err := handler.EndpointRelationService.EndpointRelation(endpoint.ID) + endpointRelation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { return err } - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { return err } - endpointStacks := portainer.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks) + endpointStacks := edge.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) + return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) } func findTagIndex(tags []portainer.TagID, searchTagID portainer.TagID) int { diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go index a19aa48e7..e4d7f2afa 100644 --- a/api/http/handler/tags/tag_list.go +++ b/api/http/handler/tags/tag_list.go @@ -9,7 +9,7 @@ import ( // GET request on /api/tags func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - tags, err := handler.TagService.Tags() + tags, err := handler.DataStore.Tag().Tags() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 0428241ec..4ca0a0fdc 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -13,8 +13,7 @@ import ( // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router - TeamMembershipService portainer.TeamMembershipService - AuthorizationService *portainer.AuthorizationService + DataStore portainer.DataStore } // NewHandler creates a handler to manage team membership operations. diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 245b1fe67..361534d07 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -1,12 +1,14 @@ package teammemberships import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -18,13 +20,13 @@ type teamMembershipCreatePayload struct { func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { if payload.UserID == 0 { - return portainer.Error("Invalid UserID") + return errors.New("Invalid UserID") } if payload.TeamID == 0 { - return portainer.Error("Invalid TeamID") + return errors.New("Invalid TeamID") } if payload.Role != 1 && payload.Role != 2 { - return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + return errors.New("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") } return nil } @@ -43,10 +45,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ } if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to manage team memberships", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to manage team memberships", httperrors.ErrResourceAccessDenied} } - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(payload.UserID)) + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(payload.UserID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} } @@ -54,7 +56,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ if len(memberships) > 0 { for _, membership := range memberships { if membership.UserID == portainer.UserID(payload.UserID) && membership.TeamID == portainer.TeamID(payload.TeamID) { - return &httperror.HandlerError{http.StatusConflict, "Team membership already registered", portainer.ErrTeamMembershipAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "Team membership already registered", errors.New("Team membership already exists for this user and team")} } } } @@ -65,15 +67,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ Role: portainer.MembershipRole(payload.Role), } - err = handler.TeamMembershipService.CreateTeamMembership(membership) + err = handler.DataStore.TeamMembership().CreateTeamMembership(membership) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} } - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index 179bbe800..f4e71d3c5 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -17,8 +19,8 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} } - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrObjectNotFound { + membership, err := handler.DataStore.TeamMembership().TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} @@ -30,18 +32,13 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ } if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the membership", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the membership", errors.ErrResourceAccessDenied} } - err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + err = handler.DataStore.TeamMembership().DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} } - err = handler.AuthorizationService.UpdateUsersAuthorizations() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} - } - return response.Empty(w) } diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go index bb0196458..77a113776 100644 --- a/api/http/handler/teammemberships/teammembership_list.go +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -5,7 +5,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -17,10 +17,10 @@ func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Reques } if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", errors.ErrResourceAccessDenied} } - memberships, err := handler.TeamMembershipService.TeamMemberships() + memberships, err := handler.DataStore.TeamMembership().TeamMemberships() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} } diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index 84ab44323..cf801a65d 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -1,12 +1,15 @@ package teammemberships import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -18,13 +21,13 @@ type teamMembershipUpdatePayload struct { func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { if payload.UserID == 0 { - return portainer.Error("Invalid UserID") + return errors.New("Invalid UserID") } if payload.TeamID == 0 { - return portainer.Error("Invalid TeamID") + return errors.New("Invalid TeamID") } if payload.Role != 1 && payload.Role != 2 { - return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + return errors.New("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") } return nil } @@ -48,25 +51,25 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ } if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", httperrors.ErrResourceAccessDenied} } - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrObjectNotFound { + membership, err := handler.DataStore.TeamMembership().TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} } if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", httperrors.ErrResourceAccessDenied} } membership.UserID = portainer.UserID(payload.UserID) membership.TeamID = portainer.TeamID(payload.TeamID) membership.Role = portainer.MembershipRole(payload.Role) - err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + err = handler.DataStore.TeamMembership().UpdateTeamMembership(membership.ID, membership) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index e5eea77fc..789dd8e7e 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -12,9 +12,7 @@ import ( // Handler is the HTTP handler used to handle team operations. type Handler struct { *mux.Router - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - AuthorizationService *portainer.AuthorizationService + DataStore portainer.DataStore } // NewHandler creates a handler to manage team operations. diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go index 3cfad7acb..583087733 100644 --- a/api/http/handler/teams/team_create.go +++ b/api/http/handler/teams/team_create.go @@ -1,6 +1,7 @@ package teams import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -8,6 +9,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type teamCreatePayload struct { @@ -16,7 +18,7 @@ type teamCreatePayload struct { func (payload *teamCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return portainer.Error("Invalid team name") + return errors.New("Invalid team name") } return nil } @@ -28,19 +30,19 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - team, err := handler.TeamService.TeamByName(payload.Name) - if err != nil && err != portainer.ErrObjectNotFound { + team, err := handler.DataStore.Team().TeamByName(payload.Name) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} } if team != nil { - return &httperror.HandlerError{http.StatusConflict, "A team with the same name already exists", portainer.ErrTeamAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A team with the same name already exists", errors.New("Team already exists")} } team = &portainer.Team{ Name: payload.Name, } - err = handler.TeamService.CreateTeam(team) + err = handler.DataStore.Team().CreateTeam(team) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} } diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index 2b96e9351..b56551a7b 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // DELETE request on /api/teams/:id @@ -16,27 +17,22 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} } - _, err = handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrObjectNotFound { + _, err = handler.DataStore.Team().Team(portainer.TeamID(teamID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} } - err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + err = handler.DataStore.Team().DeleteTeam(portainer.TeamID(teamID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the team from the database", err} } - err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + err = handler.DataStore.TeamMembership().DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} } - err = handler.AuthorizationService.RemoveTeamAccessPolicies(portainer.TeamID(teamID)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up team access policies", err} - } - return response.Empty(w) } diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go index 0eeb89e8f..81d739824 100644 --- a/api/http/handler/teams/team_inspect.go +++ b/api/http/handler/teams/team_inspect.go @@ -7,6 +7,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -23,11 +25,11 @@ func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *htt } if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", errors.ErrResourceAccessDenied} } - team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrObjectNotFound { + team, err := handler.DataStore.Team().Team(portainer.TeamID(teamID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go index da67b696b..5056a69b6 100644 --- a/api/http/handler/teams/team_list.go +++ b/api/http/handler/teams/team_list.go @@ -10,7 +10,7 @@ import ( // GET request on /api/teams func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - teams, err := handler.TeamService.Teams() + teams, err := handler.DataStore.Team().Teams() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} } diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go index e7c26fff6..75c6f1389 100644 --- a/api/http/handler/teams/team_memberships.go +++ b/api/http/handler/teams/team_memberships.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -23,10 +24,10 @@ func (handler *Handler) teamMemberships(w http.ResponseWriter, r *http.Request) } if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", errors.ErrResourceAccessDenied} } - memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(portainer.TeamID(teamID)) + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(teamID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve associated team memberships from the database", err} } diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go index 740e4fc61..eba25b11d 100644 --- a/api/http/handler/teams/team_update.go +++ b/api/http/handler/teams/team_update.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) type teamUpdatePayload struct { @@ -30,8 +31,8 @@ func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrObjectNotFound { + team, err := handler.DataStore.Team().Team(portainer.TeamID(teamID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} @@ -41,7 +42,7 @@ func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *http team.Name = payload.Name } - err = handler.TeamService.UpdateTeam(team.ID, team) + err = handler.DataStore.Team().UpdateTeam(team.ID, team) if err != nil { return &httperror.HandlerError{http.StatusNotFound, "Unable to persist team changes inside the database", err} } diff --git a/api/http/handler/templates/git.go b/api/http/handler/templates/git.go new file mode 100644 index 000000000..cc94668cd --- /dev/null +++ b/api/http/handler/templates/git.go @@ -0,0 +1,17 @@ +package templates + +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/templates/handler.go b/api/http/handler/templates/handler.go index 3eac57b4a..5c89f350f 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -9,15 +9,12 @@ import ( "github.com/portainer/portainer/api/http/security" ) -const ( - errTemplateManagementDisabled = portainer.Error("Template management is disabled") -) - // Handler represents an HTTP API handler for managing templates. type Handler struct { *mux.Router - TemplateService portainer.TemplateService - SettingsService portainer.SettingsService + DataStore portainer.DataStore + GitService portainer.GitService + FileService portainer.FileService } // NewHandler returns a new instance of Handler. @@ -28,29 +25,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/templates", bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) - h.Handle("/templates", - bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateCreate)))).Methods(http.MethodPost) - h.Handle("/templates/{id}", - bouncer.RestrictedAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateInspect)))).Methods(http.MethodGet) - h.Handle("/templates/{id}", - bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateUpdate)))).Methods(http.MethodPut) - h.Handle("/templates/{id}", - bouncer.AdminAccess(h.templateManagementCheck(httperror.LoggerHandler(h.templateDelete)))).Methods(http.MethodDelete) + h.Handle("/templates/file", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost) return h } - -func (handler *Handler) templateManagementCheck(next http.Handler) http.Handler { - return httperror.LoggerHandler(func(rw 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} - } - - if settings.TemplatesURL != "" { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Portainer is configured to use external templates, template management is disabled", errTemplateManagementDisabled} - } - - next.ServeHTTP(rw, r) - return nil - }) -} diff --git a/api/http/handler/templates/template_create.go b/api/http/handler/templates/template_create.go deleted file mode 100644 index 6c4d21a25..000000000 --- a/api/http/handler/templates/template_create.go +++ /dev/null @@ -1,122 +0,0 @@ -package templates - -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" - "github.com/portainer/portainer/api/filesystem" -) - -type templateCreatePayload struct { - // Mandatory - Type int - Title string - Description string - AdministratorOnly bool - - // Opt stack/container - Name string - Logo string - Note string - Platform string - Categories []string - Env []portainer.TemplateEnv - - // Mandatory container - Image string - - // Mandatory stack - Repository portainer.TemplateRepository - - // Opt container - Registry string - Command string - Network string - Volumes []portainer.TemplateVolume - Ports []string - Labels []portainer.Pair - Privileged bool - Interactive bool - RestartPolicy string - Hostname string -} - -func (payload *templateCreatePayload) Validate(r *http.Request) error { - if payload.Type == 0 || (payload.Type != 1 && payload.Type != 2 && payload.Type != 3) { - return portainer.Error("Invalid template type. Valid values are: 1 (container), 2 (Swarm stack template) or 3 (Compose stack template).") - } - if govalidator.IsNull(payload.Title) { - return portainer.Error("Invalid template title") - } - if govalidator.IsNull(payload.Description) { - return portainer.Error("Invalid template description") - } - - if payload.Type == 1 { - if govalidator.IsNull(payload.Image) { - return portainer.Error("Invalid template image") - } - } - - if payload.Type == 2 || payload.Type == 3 { - if govalidator.IsNull(payload.Repository.URL) { - return portainer.Error("Invalid template repository URL") - } - if govalidator.IsNull(payload.Repository.StackFile) { - payload.Repository.StackFile = filesystem.ComposeFileDefaultName - } - } - - return nil -} - -// POST request on /api/templates -func (handler *Handler) templateCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload templateCreatePayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - template := &portainer.Template{ - Type: portainer.TemplateType(payload.Type), - Title: payload.Title, - Description: payload.Description, - AdministratorOnly: payload.AdministratorOnly, - Name: payload.Name, - Logo: payload.Logo, - Note: payload.Note, - Platform: payload.Platform, - Categories: payload.Categories, - Env: payload.Env, - } - - if template.Type == portainer.ContainerTemplate { - template.Image = payload.Image - template.Registry = payload.Registry - template.Command = payload.Command - template.Network = payload.Network - template.Volumes = payload.Volumes - template.Ports = payload.Ports - template.Labels = payload.Labels - template.Privileged = payload.Privileged - template.Interactive = payload.Interactive - template.RestartPolicy = payload.RestartPolicy - template.Hostname = payload.Hostname - } - - if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate { - template.Repository = payload.Repository - } - - err = handler.TemplateService.CreateTemplate(template) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the template inside the database", err} - } - - return response.JSON(w, template) -} diff --git a/api/http/handler/templates/template_delete.go b/api/http/handler/templates/template_delete.go deleted file mode 100644 index cf82e7889..000000000 --- a/api/http/handler/templates/template_delete.go +++ /dev/null @@ -1,25 +0,0 @@ -package templates - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -// DELETE request on /api/templates/:id -func (handler *Handler) templateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - id, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} - } - - err = handler.TemplateService.DeleteTemplate(portainer.TemplateID(id)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the template from the database", err} - } - - return response.Empty(w) -} diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go new file mode 100644 index 000000000..614e3d170 --- /dev/null +++ b/api/http/handler/templates/template_file.go @@ -0,0 +1,77 @@ +package templates + +import ( + "errors" + "log" + "net/http" + "path" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +type filePayload struct { + RepositoryURL string + ComposeFilePathInRepository string +} + +type fileResponse struct { + FileContent string +} + +func (payload *filePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.RepositoryURL) { + return errors.New("Invalid repository url") + } + + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + return errors.New("Invalid file path") + } + + return nil +} + +func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload filePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + projectPath, err := handler.FileService.GetTemporaryPath() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create temporary folder", err} + } + + defer handler.cleanUp(projectPath) + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + } + + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + composeFilePath := path.Join(projectPath, payload.ComposeFilePathInRepository) + + fileContent, err := handler.FileService.GetFileContent(composeFilePath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed loading file content", err} + } + + return response.JSON(w, fileResponse{FileContent: string(fileContent)}) + +} + +func (handler *Handler) cleanUp(projectPath string) error { + err := handler.FileService.RemoveDirectory(projectPath) + if err != nil { + log.Printf("http error: Unable to cleanup stack creation (err=%s)\n", err) + } + return nil +} diff --git a/api/http/handler/templates/template_inspect.go b/api/http/handler/templates/template_inspect.go deleted file mode 100644 index bac836421..000000000 --- a/api/http/handler/templates/template_inspect.go +++ /dev/null @@ -1,27 +0,0 @@ -package templates - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -// GET request on /api/templates/:id -func (handler *Handler) templateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - templateID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} - } - - template, err := handler.TemplateService.Template(portainer.TemplateID(templateID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err} - } - - return response.JSON(w, template) -} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 41013c8ff..236b5cfdd 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -1,47 +1,30 @@ package templates import ( - "encoding/json" + "io" "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" - "github.com/portainer/portainer/api/http/security" ) // GET request on /api/templates func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } - var templates []portainer.Template - if settings.TemplatesURL == "" { - templates, err = handler.TemplateService.Templates() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates from the database", err} - } - } else { - var templateData []byte - templateData, err = client.Get(settings.TemplatesURL, 0) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} - } - - err = json.Unmarshal(templateData, &templates) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external templates", err} - } - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) + resp, err := http.Get(settings.TemplatesURL) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err} + } + defer resp.Body.Close() + + w.Header().Set("Content-Type", "application/json") + _, err = io.Copy(w, resp.Body) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to write templates from templates URL", err} } - filteredTemplates := security.FilterTemplates(templates, securityContext) - return response.JSON(w, filteredTemplates) + return nil } diff --git a/api/http/handler/templates/template_update.go b/api/http/handler/templates/template_update.go deleted file mode 100644 index fb0568aa3..000000000 --- a/api/http/handler/templates/template_update.go +++ /dev/null @@ -1,164 +0,0 @@ -package templates - -import ( - "net/http" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api" -) - -type templateUpdatePayload struct { - Title *string - Description *string - AdministratorOnly *bool - Name *string - Logo *string - Note *string - Platform *string - Categories []string - Env []portainer.TemplateEnv - Image *string - Registry *string - Repository portainer.TemplateRepository - Command *string - Network *string - Volumes []portainer.TemplateVolume - Ports []string - Labels []portainer.Pair - Privileged *bool - Interactive *bool - RestartPolicy *string - Hostname *string -} - -func (payload *templateUpdatePayload) Validate(r *http.Request) error { - return nil -} - -// PUT request on /api/templates/:id -func (handler *Handler) templateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - templateID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err} - } - - template, err := handler.TemplateService.Template(portainer.TemplateID(templateID)) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err} - } - - var payload templateUpdatePayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - updateTemplate(template, &payload) - - err = handler.TemplateService.UpdateTemplate(template.ID, template) - if err != nil { - return &httperror.HandlerError{http.StatusNotFound, "Unable to persist template changes inside the database", err} - } - - return response.JSON(w, template) -} - -func updateContainerProperties(template *portainer.Template, payload *templateUpdatePayload) { - if payload.Image != nil { - template.Image = *payload.Image - } - - if payload.Registry != nil { - template.Registry = *payload.Registry - } - - if payload.Command != nil { - template.Command = *payload.Command - } - - if payload.Network != nil { - template.Network = *payload.Network - } - - if payload.Volumes != nil { - template.Volumes = payload.Volumes - } - - if payload.Ports != nil { - template.Ports = payload.Ports - } - - if payload.Labels != nil { - template.Labels = payload.Labels - } - - if payload.Privileged != nil { - template.Privileged = *payload.Privileged - } - - if payload.Interactive != nil { - template.Interactive = *payload.Interactive - } - - if payload.RestartPolicy != nil { - template.RestartPolicy = *payload.RestartPolicy - } - - if payload.Hostname != nil { - template.Hostname = *payload.Hostname - } -} - -func updateStackProperties(template *portainer.Template, payload *templateUpdatePayload) { - if payload.Repository.URL != "" && payload.Repository.StackFile != "" { - template.Repository = payload.Repository - } -} - -func updateTemplate(template *portainer.Template, payload *templateUpdatePayload) { - if payload.Title != nil { - template.Title = *payload.Title - } - - if payload.Description != nil { - template.Description = *payload.Description - } - - if payload.Name != nil { - template.Name = *payload.Name - } - - if payload.Logo != nil { - template.Logo = *payload.Logo - } - - if payload.Note != nil { - template.Note = *payload.Note - } - - if payload.Platform != nil { - template.Platform = *payload.Platform - } - - if payload.Categories != nil { - template.Categories = payload.Categories - } - - if payload.Env != nil { - template.Env = payload.Env - } - - if payload.AdministratorOnly != nil { - template.AdministratorOnly = *payload.AdministratorOnly - } - - if template.Type == portainer.ContainerTemplate { - updateContainerProperties(template, payload) - } else if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate { - updateStackProperties(template, payload) - } -} diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go index 383f21fac..aa16544fb 100644 --- a/api/http/handler/upload/upload_tls.go +++ b/api/http/handler/upload/upload_tls.go @@ -7,6 +7,7 @@ import ( "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/upload/tls/{certificate:(?:ca|cert|key)}?folder= @@ -35,7 +36,7 @@ func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httpe case "key": fileType = portainer.TLSFileKey default: - return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route value. Value must be one of: ca, cert or key", portainer.ErrUndefinedTLSFileType} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route value. Value must be one of: ca, cert or key", filesystem.ErrUndefinedTLSFileType} } _, err = handler.FileService.StoreTLSFileFromBytes(folder, fileType, file) diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go index 0f53e6693..0bf503f7c 100644 --- a/api/http/handler/users/admin_check.go +++ b/api/http/handler/users/admin_check.go @@ -6,17 +6,18 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // GET request on /api/users/admin/check func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } if len(users) == 0 { - return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrObjectNotFound} + return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", errors.ErrObjectNotFound} } return response.Empty(w) diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index a57e95f62..ef6c5425c 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -1,6 +1,7 @@ package users import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -17,10 +18,10 @@ type adminInitPayload struct { func (payload *adminInitPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { - return portainer.Error("Invalid username. Must not contain any whitespace") + return errors.New("Invalid username. Must not contain any whitespace") } if govalidator.IsNull(payload.Password) { - return portainer.Error("Invalid password") + return errors.New("Invalid password") } return nil } @@ -33,27 +34,26 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } if len(users) != 0 { - return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", portainer.ErrAdminAlreadyInitialized} + return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", errAdminAlreadyInitialized} } user := &portainer.User{ - Username: payload.Username, - Role: portainer.AdministratorRole, - PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), + Username: payload.Username, + Role: portainer.AdministratorRole, } user.Password, err = handler.CryptoService.Hash(payload.Password) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } - err = handler.UserService.CreateUser(user) + err = handler.DataStore.User().CreateUser(user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 646bf8ae5..5ada9b87a 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -1,6 +1,8 @@ package users import ( + "errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" @@ -10,6 +12,14 @@ import ( "github.com/gorilla/mux" ) +var ( + errUserAlreadyExists = errors.New("User already exists") + errAdminAlreadyInitialized = errors.New("An administrator user already exists") + errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator") + errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account") + errCryptoHashFailure = errors.New("Unable to hash data") +) + func hideFields(user *portainer.User) { user.Password = "" } @@ -17,13 +27,8 @@ func hideFields(user *portainer.User) { // Handler is the HTTP handler used to handle user operations. type Handler struct { *mux.Router - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService - SettingsService portainer.SettingsService - AuthorizationService *portainer.AuthorizationService + DataStore portainer.DataStore + CryptoService portainer.CryptoService } // NewHandler creates a handler to manage user operations. diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index 582ca084d..e9e1fa64a 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -1,6 +1,7 @@ package users import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -8,6 +9,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -19,11 +22,11 @@ type userCreatePayload struct { func (payload *userCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { - return portainer.Error("Invalid username. Must not contain any whitespace") + return errors.New("Invalid username. Must not contain any whitespace") } if payload.Role != 1 && payload.Role != 2 { - return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") } return nil } @@ -42,28 +45,27 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http } if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", httperrors.ErrResourceAccessDenied} } if securityContext.IsTeamLeader && payload.Role == 1 { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", httperrors.ErrResourceAccessDenied} } - user, err := handler.UserService.UserByUsername(payload.Username) - if err != nil && err != portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().UserByUsername(payload.Username) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } if user != nil { - return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", portainer.ErrUserAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", errUserAlreadyExists} } user = &portainer.User{ - Username: payload.Username, - Role: portainer.UserRole(payload.Role), - PortainerAuthorizations: portainer.DefaultPortainerAuthorizations(), + Username: payload.Username, + Role: portainer.UserRole(payload.Role), } - settings, err := handler.SettingsService.Settings() + settings, err := handler.DataStore.Settings().Settings() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} } @@ -71,11 +73,11 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http if settings.AuthenticationMethod == portainer.AuthenticationInternal { user.Password, err = handler.CryptoService.Hash(payload.Password) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } } - err = handler.UserService.CreateUser(user) + err = handler.DataStore.User().CreateUser(user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 203a3be47..289b12303 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -1,12 +1,14 @@ package users import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" ) @@ -17,17 +19,21 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} } + if userID == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Cannot remove the initial admin account", errors.New("Cannot remove the initial admin account")} + } + tokenData, err := security.RetrieveTokenData(r) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} } if tokenData.ID == portainer.UserID(userID) { - return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf} + return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", errAdminCannotRemoveSelf} } - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().User(portainer.UserID(userID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} @@ -45,7 +51,7 @@ func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.U return handler.deleteUser(w, user) } - users, err := handler.UserService.Users() + users, err := handler.DataStore.User().Users() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } @@ -58,27 +64,22 @@ func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.U } if localAdminCount < 2 { - return &httperror.HandlerError{http.StatusInternalServerError, "Cannot remove local administrator user", portainer.ErrCannotRemoveLastLocalAdmin} + return &httperror.HandlerError{http.StatusInternalServerError, "Cannot remove local administrator user", errCannotRemoveLastLocalAdmin} } return handler.deleteUser(w, user) } func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { - err := handler.UserService.DeleteUser(user.ID) + err := handler.DataStore.User().DeleteUser(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} } - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(user.ID) + err = handler.DataStore.TeamMembership().DeleteTeamMembershipByUserID(user.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } - err = handler.AuthorizationService.RemoveUserAccessPolicies(user.ID) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up user access policies", err} - } - return response.Empty(w) } diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go index 4c5954283..cfd87efd0 100644 --- a/api/http/handler/users/user_inspect.go +++ b/api/http/handler/users/user_inspect.go @@ -3,12 +3,13 @@ package users import ( "net/http" - "github.com/portainer/portainer/api/http/security" - httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" ) // GET request on /api/users/:id @@ -24,11 +25,11 @@ func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *htt } if !securityContext.IsAdmin && securityContext.UserID != portainer.UserID(userID) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied inspect user", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied inspect user", errors.ErrResourceAccessDenied} } - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().User(portainer.UserID(userID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go index 5792689a0..f79b65bb3 100644 --- a/api/http/handler/users/user_list.go +++ b/api/http/handler/users/user_list.go @@ -10,7 +10,7 @@ import ( // GET request on /api/users func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - users, err := handler.UserService.Users() + users, err := handler.DataStore.User().Users() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go index 090880e6b..283b6ee25 100644 --- a/api/http/handler/users/user_memberships.go +++ b/api/http/handler/users/user_memberships.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -23,10 +24,10 @@ func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) } if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user memberships", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user memberships", errors.ErrUnauthorized} } - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} } diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 62ba24f46..dd1f57519 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -1,23 +1,32 @@ package users import ( + "errors" "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" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) type userUpdatePayload struct { + Username string Password string Role int } func (payload *userUpdatePayload) Validate(r *http.Request) error { + if govalidator.Contains(payload.Username, " ") { + return errors.New("Invalid username. Must not contain any whitespace") + } + if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { - return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") } return nil } @@ -35,7 +44,7 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http } if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", httperrors.ErrUnauthorized} } var payload userUpdatePayload @@ -45,20 +54,32 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http } if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user to administrator role", portainer.ErrResourceAccessDenied} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user to administrator role", httperrors.ErrResourceAccessDenied} } - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().User(portainer.UserID(userID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} } + if payload.Username != "" && payload.Username != user.Username { + sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username) + if err != nil && err != bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + if sameNameUser != nil && sameNameUser.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", errUserAlreadyExists} + } + + user.Username = payload.Username + } + if payload.Password != "" { user.Password, err = handler.CryptoService.Hash(payload.Password) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } } @@ -66,7 +87,7 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http user.Role = portainer.UserRole(payload.Role) } - err = handler.UserService.UpdateUser(user.ID, user) + err = handler.DataStore.User().UpdateUser(user.ID, user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} } diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index 77ce99b49..c0556dfd1 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -1,6 +1,7 @@ package users import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -8,6 +9,8 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" ) @@ -18,10 +21,10 @@ type userUpdatePasswordPayload struct { func (payload *userUpdatePasswordPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Password) { - return portainer.Error("Invalid current password") + return errors.New("Invalid current password") } if govalidator.IsNull(payload.NewPassword) { - return portainer.Error("Invalid new password") + return errors.New("Invalid new password") } return nil } @@ -39,7 +42,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques } if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", httperrors.ErrUnauthorized} } var payload userUpdatePasswordPayload @@ -48,8 +51,8 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrObjectNotFound { + user, err := handler.DataStore.User().User(portainer.UserID(userID)) + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} @@ -57,15 +60,15 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized} } user.Password, err = handler.CryptoService.Hash(payload.NewPassword) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } - err = handler.UserService.UpdateUser(user.ID, user) + err = handler.DataStore.User().UpdateUser(user.ID, user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} } diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go index 2e342114e..aa5046cfe 100644 --- a/api/http/handler/webhooks/handler.go +++ b/api/http/handler/webhooks/handler.go @@ -13,8 +13,7 @@ import ( // Handler is the HTTP handler used to handle webhook operations. type Handler struct { *mux.Router - WebhookService portainer.WebhookService - EndpointService portainer.EndpointService + DataStore portainer.DataStore DockerClientFactory *docker.ClientFactory } diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index 883521608..dc00dcd38 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -1,6 +1,7 @@ package webhooks import ( + "errors" "net/http" "github.com/asaskevich/govalidator" @@ -9,6 +10,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) type webhookCreatePayload struct { @@ -19,13 +21,13 @@ type webhookCreatePayload struct { func (payload *webhookCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.ResourceID) { - return portainer.Error("Invalid ResourceID") + return errors.New("Invalid ResourceID") } if payload.EndpointID == 0 { - return portainer.Error("Invalid EndpointID") + return errors.New("Invalid EndpointID") } if payload.WebhookType != 1 { - return portainer.Error("Invalid WebhookType") + return errors.New("Invalid WebhookType") } return nil } @@ -37,12 +39,12 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - webhook, err := handler.WebhookService.WebhookByResourceID(payload.ResourceID) - if err != nil && err != portainer.ErrObjectNotFound { + webhook, err := handler.DataStore.Webhook().WebhookByResourceID(payload.ResourceID) + if err != nil && err != bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "An error occurred retrieving webhooks from the database", err} } if webhook != nil { - return &httperror.HandlerError{http.StatusConflict, "A webhook for this resource already exists", portainer.ErrWebhookAlreadyExists} + return &httperror.HandlerError{http.StatusConflict, "A webhook for this resource already exists", errors.New("A webhook for this resource already exists")} } token, err := uuid.NewV4() @@ -57,7 +59,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h WebhookType: portainer.WebhookType(payload.WebhookType), } - err = handler.WebhookService.CreateWebhook(webhook) + err = handler.DataStore.Webhook().CreateWebhook(webhook) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the webhook inside the database", err} } diff --git a/api/http/handler/webhooks/webhook_delete.go b/api/http/handler/webhooks/webhook_delete.go index ee36f822c..305dbca6b 100644 --- a/api/http/handler/webhooks/webhook_delete.go +++ b/api/http/handler/webhooks/webhook_delete.go @@ -16,7 +16,7 @@ func (handler *Handler) webhookDelete(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid webhook id", err} } - err = handler.WebhookService.DeleteWebhook(portainer.WebhookID(id)) + err = handler.DataStore.Webhook().DeleteWebhook(portainer.WebhookID(id)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the webhook from the database", err} } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index 2e82f0e1a..e07ae8b2e 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -2,6 +2,7 @@ package webhooks import ( "context" + "errors" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" ) // Acts on a passed in token UUID to restart the docker service @@ -21,9 +23,9 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Invalid service id parameter", err} } - webhook, err := handler.WebhookService.WebhookByToken(webhookToken) + webhook, err := handler.DataStore.Webhook().WebhookByToken(webhookToken) - if err == portainer.ErrObjectNotFound { + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a webhook with this token", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve webhook from the database", err} @@ -33,8 +35,8 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * endpointID := webhook.EndpointID webhookType := webhook.WebhookType - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} @@ -46,7 +48,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * case portainer.ServiceWebhook: return handler.executeServiceWebhook(w, endpoint, resourceID, imageTag) default: - return &httperror.HandlerError{http.StatusInternalServerError, "Unsupported webhook type", portainer.ErrUnsupportedWebhookType} + return &httperror.HandlerError{http.StatusInternalServerError, "Unsupported webhook type", errors.New("Webhooks for this resource are not currently supported")} } } diff --git a/api/http/handler/webhooks/webhook_list.go b/api/http/handler/webhooks/webhook_list.go index a45051884..df9f5550c 100644 --- a/api/http/handler/webhooks/webhook_list.go +++ b/api/http/handler/webhooks/webhook_list.go @@ -22,7 +22,7 @@ func (handler *Handler) webhookList(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} } - webhooks, err := handler.WebhookService.Webhooks() + webhooks, err := handler.DataStore.Webhook().Webhooks() webhooks = filterWebhooks(webhooks, &filters) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve webhooks from the database", err} diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index 5de214eca..ea43cd9cd 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -11,6 +11,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) // websocketAttach handles GET requests on /websocket/attach?id=&endpointId=&nodeName=&token= @@ -32,14 +33,14 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -64,7 +65,7 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque if params.endpoint.Type == portainer.AgentOnDockerEnvironment { return handler.proxyAgentWebsocketRequest(w, r, params) - } else if params.endpoint.Type == portainer.EdgeAgentEnvironment { + } else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { return handler.proxyEdgeAgentWebsocketRequest(w, r, params) } diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index afe670a56..836cf252c 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -3,6 +3,7 @@ package websocket import ( "bytes" "encoding/json" + "github.com/portainer/portainer/api/bolt/errors" "net" "net/http" "net/http/httputil" @@ -39,14 +40,14 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrObjectNotFound { + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == errors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -70,7 +71,7 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request if params.endpoint.Type == portainer.AgentOnDockerEnvironment { return handler.proxyAgentWebsocketRequest(w, r, params) - } else if params.endpoint.Type == portainer.EdgeAgentEnvironment { + } else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { return handler.proxyEdgeAgentWebsocketRequest(w, r, params) } diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index cc0165eb0..05cd88cfc 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -4,18 +4,20 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" ) // Handler is the HTTP handler used to handle websocket operations. type Handler struct { *mux.Router - EndpointService portainer.EndpointService - SignatureService portainer.DigitalSignatureService - ReverseTunnelService portainer.ReverseTunnelService - requestBouncer *security.RequestBouncer - connectionUpgrader websocket.Upgrader + DataStore portainer.DataStore + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + KubernetesClientFactory *cli.ClientFactory + requestBouncer *security.RequestBouncer + connectionUpgrader websocket.Upgrader } // NewHandler creates a handler to manage websocket operations. @@ -29,5 +31,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) h.PathPrefix("/websocket/attach").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) + h.PathPrefix("/websocket/pod").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketPodExec))) return h } diff --git a/api/http/handler/websocket/hijack.go b/api/http/handler/websocket/hijack.go index f8a7b6624..a991f3bec 100644 --- a/api/http/handler/websocket/hijack.go +++ b/api/http/handler/websocket/hijack.go @@ -2,9 +2,10 @@ package websocket import ( "fmt" - "github.com/gorilla/websocket" "net/http" "net/http/httputil" + + "github.com/gorilla/websocket" ) func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { @@ -24,8 +25,8 @@ func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, defer tcpConn.Close() errorChan := make(chan error, 1) - go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan) - go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan) + go streamFromReaderToWebsocket(websocketConn, brw, errorChan) + go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan) err = <-errorChan if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go new file mode 100644 index 000000000..b90f096b3 --- /dev/null +++ b/api/http/handler/websocket/pod.go @@ -0,0 +1,117 @@ +package websocket + +import ( + "io" + "log" + "net/http" + "strings" + + "github.com/gorilla/websocket" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// websocketPodExec handles GET requests on /websocket/pod?token=&endpointId=&namespace=&podName=&containerName=&command= +// The request will be upgraded to the websocket protocol. +// Authentication and access is controlled via the mandatory token query parameter. +// The following parameters query parameters are mandatory: +// * token: JWT token used for authentication against this endpoint +// * endpointId: endpoint ID of the endpoint where the resource is located +// * namespace: namespace where the container is located +// * podName: name of the pod containing the container +// * containerName: name of the container +// * command: command to execute in the container +func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + namespace, err := request.RetrieveQueryParameter(r, "namespace", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: namespace", err} + } + + podName, err := request.RetrieveQueryParameter(r, "podName", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: podName", err} + } + + containerName, err := request.RetrieveQueryParameter(r, "containerName", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: containerName", err} + } + + command, err := request.RetrieveQueryParameter(r, "command", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: command", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + params := &webSocketRequestParams{ + endpoint: endpoint, + } + + r.Header.Del("Origin") + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment { + err := handler.proxyAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to agent", err} + } + return nil + } else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.proxyEdgeAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to Edge agent", err} + } + return nil + } + + commandArray := strings.Split(command, " ") + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to upgrade the connection", err} + } + defer websocketConn.Close() + + stdinReader, stdinWriter := io.Pipe() + defer stdinWriter.Close() + stdoutReader, stdoutWriter := io.Pipe() + defer stdoutWriter.Close() + + errorChan := make(chan error, 1) + go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan) + go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) + + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} + } + + err = <-errorChan + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + log.Printf("websocket error: %s \n", err.Error()) + } + + return nil +} diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go index bd8e3f4f7..984240256 100644 --- a/api/http/handler/websocket/proxy.go +++ b/api/http/handler/websocket/proxy.go @@ -33,7 +33,12 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r } func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { - agentURL, err := url.Parse(params.endpoint.URL) + endpointURL := params.endpoint.URL + if params.endpoint.Type == portainer.AgentOnKubernetesEnvironment { + endpointURL = fmt.Sprintf("http://%s", params.endpoint.URL) + } + + agentURL, err := url.Parse(endpointURL) if err != nil { return err } diff --git a/api/http/handler/websocket/stream.go b/api/http/handler/websocket/stream.go index a598b7cb7..131951803 100644 --- a/api/http/handler/websocket/stream.go +++ b/api/http/handler/websocket/stream.go @@ -1,13 +1,15 @@ package websocket import ( - "bufio" - "github.com/gorilla/websocket" - "net" + "io" "unicode/utf8" + + "github.com/gorilla/websocket" ) -func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) { +const readerBufferSize = 2048 + +func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer, errorChan chan error) { for { _, in, err := websocketConn.ReadMessage() if err != nil { @@ -15,7 +17,7 @@ func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net break } - _, err = tcpConn.Write(in) + _, err = writer.Write(in) if err != nil { errorChan <- err break @@ -23,10 +25,10 @@ func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net } } -func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) { +func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) { for { - out := make([]byte, 2048) - _, err := br.Read(out) + out := make([]byte, readerBufferSize) + _, err := reader.Read(out) if err != nil { errorChan <- err break diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go index acf0731bd..513b5731d 100644 --- a/api/http/proxy/factory/docker.go +++ b/api/http/proxy/factory/docker.go @@ -32,7 +32,7 @@ func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) ( } func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) endpoint.URL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port) } @@ -56,18 +56,11 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h } transportParameters := &docker.TransportParameters{ - Endpoint: endpoint, - ResourceControlService: factory.resourceControlService, - UserService: factory.userService, - TeamService: factory.teamService, - TeamMembershipService: factory.teamMembershipService, - RegistryService: factory.registryService, - DockerHubService: factory.dockerHubService, - SettingsService: factory.settingsService, - ReverseTunnelService: factory.reverseTunnelService, - ExtensionService: factory.extensionService, - SignatureService: factory.signatureService, - DockerClientFactory: factory.dockerClientFactory, + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, } dockerTransport, err := docker.NewTransport(transportParameters, httpTransport) diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 2b8e183f9..fda598cce 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api" ) @@ -30,9 +31,9 @@ type ( func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject map[string]interface{}, resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { if labelsObject[resourceLabelForPortainerPublicResourceControl] != nil { - resourceControl := portainer.NewPublicResourceControl(resourceID, resourceType) + resourceControl := authorization.NewPublicResourceControl(resourceID, resourceType) - err := transport.resourceControlService.CreateResourceControl(resourceControl) + err := transport.dataStore.ResourceControl().CreateResourceControl(resourceControl) if err != nil { return nil, err } @@ -57,7 +58,7 @@ func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject m userIDs := make([]portainer.UserID, 0) for _, name := range teamNames { - team, err := transport.teamService.TeamByName(name) + team, err := transport.dataStore.Team().TeamByName(name) if err != nil { log.Printf("[WARN] [http,proxy,docker] [message: unknown team name in access control label, ignoring access control rule for this team] [name: %s] [resource_id: %s]", name, resourceID) continue @@ -67,7 +68,7 @@ func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject m } for _, name := range userNames { - user, err := transport.userService.UserByUsername(name) + user, err := transport.dataStore.User().UserByUsername(name) if err != nil { log.Printf("[WARN] [http,proxy,docker] [message: unknown user name in access control label, ignoring access control rule for this user] [name: %s] [resource_id: %s]", name, resourceID) continue @@ -76,9 +77,9 @@ func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject m userIDs = append(userIDs, user.ID) } - resourceControl := portainer.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs) + resourceControl := authorization.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs) - err := transport.resourceControlService.CreateResourceControl(resourceControl) + err := transport.dataStore.ResourceControl().CreateResourceControl(resourceControl) if err != nil { return nil, err } @@ -90,9 +91,9 @@ func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject m } func (transport *Transport) createPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) (*portainer.ResourceControl, error) { - resourceControl := portainer.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) + resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) - err := transport.resourceControlService.CreateResourceControl(resourceControl) + err := transport.dataStore.ResourceControl().CreateResourceControl(resourceControl) if err != nil { log.Printf("[ERROR] [http,proxy,docker,transport] [message: unable to persist resource control] [resource: %s] [err: %s]", resourceIdentifier, err) return nil, err @@ -154,11 +155,11 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe return err } - if resourceControl == nil && (executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess) { + if resourceControl == nil && (executor.operationContext.isAdmin) { return responseutils.RewriteResponse(response, responseObject, http.StatusOK) } - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess || (resourceControl != nil && portainer.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) { + if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) { responseObject = decorateObject(responseObject, resourceControl) return responseutils.RewriteResponse(response, responseObject, http.StatusOK) } @@ -167,7 +168,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe } func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) { - if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess { + if executor.operationContext.isAdmin { return transport.decorateResourceList(parameters, resourceData, executor.operationContext.resourceControls) } @@ -240,13 +241,13 @@ func (transport *Transport) filterResourceList(parameters *resourceOperationPara } if resourceControl == nil { - if context.isAdmin || context.endpointResourceAccess { + if context.isAdmin { filteredResourceData = append(filteredResourceData, resourceObject) } continue } - if context.isAdmin || context.endpointResourceAccess || portainer.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) { + if context.isAdmin || authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) { resourceObject = decorateObject(resourceObject, resourceControl) filteredResourceData = append(filteredResourceData, resourceObject) } @@ -256,7 +257,7 @@ func (transport *Transport) filterResourceList(parameters *resourceOperationPara } func (transport *Transport) findResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, resourceLabelsObject map[string]interface{}, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { - resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls) + resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls) if resourceControl != nil { return resourceControl, nil } @@ -264,7 +265,7 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou if resourceLabelsObject != nil { if resourceLabelsObject[resourceLabelForDockerServiceID] != nil { inheritedServiceIdentifier := resourceLabelsObject[resourceLabelForDockerServiceID].(string) - resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls) + resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls) if resourceControl != nil { return resourceControl, nil @@ -273,7 +274,7 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != nil { inheritedSwarmStackIdentifier := resourceLabelsObject[resourceLabelForDockerSwarmStackName].(string) - resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedSwarmStackIdentifier, portainer.StackResourceControl, resourceControls) + resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedSwarmStackIdentifier, portainer.StackResourceControl, resourceControls) if resourceControl != nil { return resourceControl, nil @@ -282,7 +283,7 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou if resourceLabelsObject[resourceLabelForDockerComposeStackName] != nil { inheritedComposeStackIdentifier := resourceLabelsObject[resourceLabelForDockerComposeStackName].(string) - resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedComposeStackIdentifier, portainer.StackResourceControl, resourceControls) + resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedComposeStackIdentifier, portainer.StackResourceControl, resourceControls) if resourceControl != nil { return resourceControl, nil diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go index 540bbdef9..0fee44bbc 100644 --- a/api/http/proxy/factory/docker/configs.go +++ b/api/http/proxy/factory/docker/configs.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -22,7 +23,7 @@ func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, co swarmStackName := config.Spec.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go index c0587d9c8..3f9ecf9a1 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -1,12 +1,18 @@ package docker import ( + "bytes" "context" + "encoding/json" + "errors" + "io/ioutil" "net/http" "github.com/docker/docker/client" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -21,7 +27,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, serviceName := container.Config.Labels[resourceLabelForDockerServiceID] if serviceName != "" { - serviceResourceControl := portainer.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls) + serviceResourceControl := authorization.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls) if serviceResourceControl != nil { return serviceResourceControl, nil } @@ -29,12 +35,12 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, swarmStackName := container.Config.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } composeStackName := container.Config.Labels[resourceLabelForDockerComposeStackName] if composeStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(composeStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(composeStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil @@ -147,3 +153,81 @@ func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelB return false } + +func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { + type PartialContainer struct { + HostConfig struct { + Privileged bool `json:"Privileged"` + PidMode string `json:"PidMode"` + Devices []interface{} `json:"Devices"` + CapAdd []string `json:"CapAdd"` + CapDrop []string `json:"CapDrop"` + Binds []string `json:"Binds"` + } `json:"HostConfig"` + } + + forbiddenResponse := &http.Response{ + StatusCode: http.StatusForbidden, + } + + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if !isAdminOrEndpointAdmin { + settings, err := transport.dataStore.Settings().Settings() + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(request.Body) + if err != nil { + return nil, err + } + + partialContainer := &PartialContainer{} + err = json.Unmarshal(body, partialContainer) + if err != nil { + return nil, err + } + + if !settings.AllowPrivilegedModeForRegularUsers && partialContainer.HostConfig.Privileged { + return forbiddenResponse, errors.New("forbidden to use privileged mode") + } + + if !settings.AllowHostNamespaceForRegularUsers && partialContainer.HostConfig.PidMode == "host" { + return forbiddenResponse, errors.New("forbidden to use pid host namespace") + } + + if !settings.AllowDeviceMappingForRegularUsers && len(partialContainer.HostConfig.Devices) > 0 { + return forbiddenResponse, errors.New("forbidden to use device mapping") + } + + if !settings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) { + return nil, errors.New("forbidden to use container capabilities") + } + + if !settings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) { + return forbiddenResponse, errors.New("forbidden to use bind mounts") + } + + request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + } + + 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/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go index c50b716e2..4c3c06d48 100644 --- a/api/http/proxy/factory/docker/networks.go +++ b/api/http/proxy/factory/docker/networks.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -25,7 +26,7 @@ func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, n swarmStackName := network.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil @@ -85,7 +86,7 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por networkName := networkObject[networkObjectName].(string) if networkName == "bridge" || networkName == "host" || networkName == "none" { - return portainer.NewSystemResourceControl(networkID, portainer.NetworkResourceControl) + return authorization.NewSystemResourceControl(networkID, portainer.NetworkResourceControl) } return nil diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go index 522597bdb..b57627d8d 100644 --- a/api/http/proxy/factory/docker/secrets.go +++ b/api/http/proxy/factory/docker/secrets.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -22,7 +23,7 @@ func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, se swarmStackName := secret.Spec.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go index eb1aa6e80..08f01a23c 100644 --- a/api/http/proxy/factory/docker/services.go +++ b/api/http/proxy/factory/docker/services.go @@ -1,7 +1,11 @@ package docker import ( + "bytes" "context" + "encoding/json" + "errors" + "io/ioutil" "net/http" "github.com/docker/docker/api/types" @@ -9,6 +13,7 @@ import ( "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" + "github.com/portainer/portainer/api/internal/authorization" ) const ( @@ -23,7 +28,7 @@ func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, s swarmStackName := service.Spec.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil @@ -84,3 +89,54 @@ func selectorServiceLabels(responseObject map[string]interface{}) map[string]int } return nil } + +func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) { + type PartialService struct { + TaskTemplate struct { + ContainerSpec struct { + Mounts []struct { + Type string + } + } + } + } + + forbiddenResponse := &http.Response{ + StatusCode: http.StatusForbidden, + } + + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if !isAdminOrEndpointAdmin { + settings, err := transport.dataStore.Settings().Settings() + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(request.Body) + if err != nil { + return nil, err + } + + partialService := &PartialService{} + err = json.Unmarshal(body, partialService) + if err != nil { + return nil, err + } + + if !settings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) { + for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts { + if mount.Type == "bind" { + return forbiddenResponse, errors.New("forbidden to use bind mounts") + } + } + } + + request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + } + + return transport.replaceRegistryAuthenticationHeader(request) +} diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index c511948fa..bb49bfa4b 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -10,12 +10,12 @@ import ( "regexp" "strings" - "github.com/portainer/portainer/api/docker" - "github.com/docker/docker/client" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) @@ -24,44 +24,29 @@ type ( // Transport is a custom transport for Docker API reverse proxy. It allows // interception of requests and rewriting of responses. Transport struct { - HTTPTransport *http.Transport - endpoint *portainer.Endpoint - resourceControlService portainer.ResourceControlService - userService portainer.UserService - teamService portainer.TeamService - teamMembershipService portainer.TeamMembershipService - registryService portainer.RegistryService - dockerHubService portainer.DockerHubService - settingsService portainer.SettingsService - signatureService portainer.DigitalSignatureService - reverseTunnelService portainer.ReverseTunnelService - extensionService portainer.ExtensionService - dockerClient *client.Client - dockerClientFactory *docker.ClientFactory + HTTPTransport *http.Transport + endpoint *portainer.Endpoint + dataStore portainer.DataStore + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + dockerClient *client.Client + dockerClientFactory *docker.ClientFactory } // TransportParameters is used to create a new Transport TransportParameters struct { - Endpoint *portainer.Endpoint - ResourceControlService portainer.ResourceControlService - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - SettingsService portainer.SettingsService - SignatureService portainer.DigitalSignatureService - ReverseTunnelService portainer.ReverseTunnelService - ExtensionService portainer.ExtensionService - DockerClientFactory *docker.ClientFactory + Endpoint *portainer.Endpoint + DataStore portainer.DataStore + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + DockerClientFactory *docker.ClientFactory } restrictedDockerOperationContext struct { - isAdmin bool - endpointResourceAccess bool - userID portainer.UserID - userTeamIDs []portainer.TeamID - resourceControls []portainer.ResourceControl + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl } operationExecutor struct { @@ -80,20 +65,13 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport } transport := &Transport{ - endpoint: parameters.Endpoint, - resourceControlService: parameters.ResourceControlService, - userService: parameters.UserService, - teamService: parameters.TeamService, - teamMembershipService: parameters.TeamMembershipService, - registryService: parameters.RegistryService, - dockerHubService: parameters.DockerHubService, - settingsService: parameters.SettingsService, - signatureService: parameters.SignatureService, - reverseTunnelService: parameters.ReverseTunnelService, - extensionService: parameters.ExtensionService, - dockerClientFactory: parameters.DockerClientFactory, - HTTPTransport: httpTransport, - dockerClient: dockerClient, + endpoint: parameters.Endpoint, + dataStore: parameters.DataStore, + signatureService: parameters.SignatureService, + reverseTunnelService: parameters.ReverseTunnelService, + dockerClientFactory: parameters.DockerClientFactory, + HTTPTransport: httpTransport, + dockerClient: dockerClient, } return transport, nil @@ -153,7 +131,7 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) { response, err := transport.HTTPTransport.RoundTrip(request) - if transport.endpoint.Type != portainer.EdgeAgentEnvironment { + if transport.endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { return response, err } @@ -177,8 +155,14 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, return transport.administratorOperation(r) } + agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader) + resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeIDParameter[0]) + if err != nil { + return nil, err + } + // volume browser request - return transport.restrictedResourceOperation(r, volumeIDParameter[0], portainer.VolumeResourceControl, true) + return transport.restrictedResourceOperation(r, resourceID, portainer.VolumeResourceControl, true) } return transport.executeDockerRequest(r) @@ -209,7 +193,7 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/containers/create": - return transport.decorateGenericResourceCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl) + return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl) case "/containers/prune": return transport.administratorOperation(request) @@ -245,7 +229,7 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http. func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/services/create": - return transport.replaceRegistryAuthenticationHeader(request) + return transport.decorateServiceCreationOperation(request) case "/services": return transport.rewriteOperation(request, transport.serviceListOperation) @@ -285,14 +269,7 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Res default: // assume /volumes/{name} - volumeID := path.Base(requestPath) - - if request.Method == http.MethodGet { - return transport.rewriteOperation(request, transport.volumeInspectOperation) - } else if request.Method == http.MethodDelete { - return transport.executeGenericResourceDeletionOperation(request, volumeID, portainer.VolumeResourceControl) - } - return transport.restrictedResourceOperation(request, volumeID, portainer.VolumeResourceControl, false) + return transport.restrictedVolumeOperation(requestPath, request) } } @@ -429,46 +406,18 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if tokenData.Role != portainer.AdministratorRole { - rbacExtension, err := transport.extensionService.Extension(portainer.RBACExtension) - if err != nil && err != portainer.ErrObjectNotFound { - return nil, err - } - - user, err := transport.userService.User(tokenData.ID) - if err != nil { - return nil, err - } - if volumeBrowseRestrictionCheck { - settings, err := transport.settingsService.Settings() + settings, err := transport.dataStore.Settings().Settings() if err != nil { return nil, err } 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() - } + return responseutils.WriteAccessDeniedResponse() } } - endpointResourceAccess := false - _, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] - if ok { - endpointResourceAccess = true - } - - if rbacExtension != nil && endpointResourceAccess { - return transport.executeDockerRequest(request) - } - - teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return nil, err } @@ -478,12 +427,12 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r userTeamIDs = append(userTeamIDs, membership.TeamID) } - resourceControls, err := transport.resourceControlService.ResourceControls() + resourceControls, err := transport.dataStore.ResourceControl().ResourceControls() if err != nil { return nil, err } - resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) + resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) if resourceControl == nil { agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) @@ -494,12 +443,12 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r return nil, err } - if inheritedResourceControl == nil || !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { + if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { return responseutils.WriteAccessDeniedResponse() } } - if resourceControl != nil && !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { return responseutils.WriteAccessDeniedResponse() } } @@ -516,7 +465,7 @@ func (transport *Transport) rewriteOperationWithLabelFiltering(request *http.Req return nil, err } - settings, err := transport.settingsService.Settings() + settings, err := transport.dataStore.Settings().Settings() if err != nil { return nil, err } @@ -610,13 +559,13 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt return response, err } - resourceControl, err := transport.resourceControlService.ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType) + resourceControl, err := transport.dataStore.ResourceControl().ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType) if err != nil { return response, err } if resourceControl != nil { - err = transport.resourceControlService.DeleteResourceControl(resourceControl.ID) + err = transport.dataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) if err != nil { return response, err } @@ -661,13 +610,13 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( userID: tokenData.ID, } - hub, err := transport.dockerHubService.DockerHub() + hub, err := transport.dataStore.DockerHub().DockerHub() if err != nil { return nil, err } accessContext.dockerHub = hub - registries, err := transport.registryService.Registries() + registries, err := transport.dataStore.Registry().Registries() if err != nil { return nil, err } @@ -676,7 +625,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) ( if tokenData.Role != portainer.AdministratorRole { accessContext.isAdmin = false - teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return nil, err } @@ -694,32 +643,21 @@ func (transport *Transport) createOperationContext(request *http.Request) (*rest return nil, err } - resourceControls, err := transport.resourceControlService.ResourceControls() + resourceControls, err := transport.dataStore.ResourceControl().ResourceControls() if err != nil { return nil, err } operationContext := &restrictedDockerOperationContext{ - isAdmin: true, - userID: tokenData.ID, - resourceControls: resourceControls, - endpointResourceAccess: false, + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, } if tokenData.Role != portainer.AdministratorRole { operationContext.isAdmin = false - user, err := transport.userService.User(operationContext.userID) - if err != nil { - return nil, err - } - - _, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] - if ok { - operationContext.endpointResourceAccess = true - } - - teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return nil, err } @@ -733,3 +671,12 @@ func (transport *Transport) createOperationContext(request *http.Request) (*rest return operationContext, nil } + +func (transport *Transport) isAdminOrEndpointAdmin(request *http.Request) (bool, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return false, err + } + + return tokenData.Role == portainer.AdministratorRole, nil +} diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index 5727d0157..0d9705f82 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -4,16 +4,18 @@ import ( "context" "errors" "net/http" + "path" "github.com/docker/docker/client" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/responseutils" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) const ( - volumeObjectIdentifier = "Name" + volumeObjectIdentifier = "ID" ) func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { @@ -24,7 +26,7 @@ func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, vo swarmStackName := volume.Labels[resourceLabelForDockerSwarmStackName] if swarmStackName != "" { - return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil + return authorization.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil } return nil, nil @@ -44,6 +46,14 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo if responseObject["Volumes"] != nil { volumeData := responseObject["Volumes"].([]interface{}) + for _, volumeObject := range volumeData { + volume := volumeObject.(map[string]interface{}) + if volume["Name"] == nil || volume["CreatedAt"] == nil { + return errors.New("missing identifier in Docker resource list response") + } + volume[volumeObjectIdentifier] = volume["Name"].(string) + volume["CreatedAt"].(string) + } + resourceOperationParameters := &resourceOperationParameters{ resourceIdentifierAttribute: volumeObjectIdentifier, resourceType: portainer.VolumeResourceControl, @@ -54,7 +64,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo if err != nil { return err } - // Overwrite the original volume list responseObject["Volumes"] = volumeData } @@ -72,6 +81,11 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec return err } + if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil { + return errors.New("missing identifier in Docker resource detail response") + } + responseObject[volumeObjectIdentifier] = responseObject["Name"].(string) + responseObject["CreatedAt"].(string) + resourceOperationParameters := &resourceOperationParameters{ resourceIdentifierAttribute: volumeObjectIdentifier, resourceType: portainer.VolumeResourceControl, @@ -122,7 +136,62 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt } if response.StatusCode == http.StatusCreated { - err = transport.decorateGenericResourceCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID) + err = transport.decorateVolumeCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID) } return response, err } + +func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { + responseObject, err := responseutils.GetResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil { + return errors.New("missing identifier in Docker resource creation response") + } + resourceID := responseObject["Name"].(string) + responseObject["CreatedAt"].(string) + + resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID) + if err != nil { + return err + } + + responseObject = decorateObject(responseObject, resourceControl) + + return responseutils.RewriteResponse(response, responseObject, http.StatusOK) +} + +func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) { + + if request.Method == http.MethodGet { + return transport.rewriteOperation(request, transport.volumeInspectOperation) + } + + agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) + + resourceID, err := transport.getVolumeResourceID(agentTargetHeader, path.Base(requestPath)) + if err != nil { + return nil, err + } + + if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, resourceID, portainer.VolumeResourceControl) + } + return transport.restrictedResourceOperation(request, resourceID, portainer.VolumeResourceControl, false) +} + +func (transport *Transport) getVolumeResourceID(nodename, volumeID string) (string, error) { + cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodename) + if err != nil { + return "", err + } + defer cli.Close() + + volume, err := cli.VolumeInspect(context.Background(), volumeID) + if err != nil { + return "", err + } + + return volume.Name + volume.CreatedAt, nil +} diff --git a/api/http/proxy/factory/docker_unix.go b/api/http/proxy/factory/docker_unix.go index 214b50747..32a572d30 100644 --- a/api/http/proxy/factory/docker_unix.go +++ b/api/http/proxy/factory/docker_unix.go @@ -12,18 +12,11 @@ import ( func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { transportParameters := &docker.TransportParameters{ - Endpoint: endpoint, - ResourceControlService: factory.resourceControlService, - UserService: factory.userService, - TeamService: factory.teamService, - TeamMembershipService: factory.teamMembershipService, - RegistryService: factory.registryService, - DockerHubService: factory.dockerHubService, - SettingsService: factory.settingsService, - ReverseTunnelService: factory.reverseTunnelService, - ExtensionService: factory.extensionService, - SignatureService: factory.signatureService, - DockerClientFactory: factory.dockerClientFactory, + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, } proxy := &dockerLocalProxy{} diff --git a/api/http/proxy/factory/docker_windows.go b/api/http/proxy/factory/docker_windows.go index 50ba768f7..fb71b91d1 100644 --- a/api/http/proxy/factory/docker_windows.go +++ b/api/http/proxy/factory/docker_windows.go @@ -13,18 +13,11 @@ import ( func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { transportParameters := &docker.TransportParameters{ - Endpoint: endpoint, - ResourceControlService: factory.resourceControlService, - UserService: factory.userService, - TeamService: factory.teamService, - TeamMembershipService: factory.teamMembershipService, - RegistryService: factory.registryService, - DockerHubService: factory.dockerHubService, - SettingsService: factory.settingsService, - ReverseTunnelService: factory.reverseTunnelService, - ExtensionService: factory.extensionService, - SignatureService: factory.signatureService, - DockerClientFactory: factory.dockerClientFactory, + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, } proxy := &dockerLocalProxy{} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index 207977f21..dd4e62440 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -1,91 +1,44 @@ package factory import ( - "fmt" "net/http" "net/http/httputil" "net/url" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/docker" ) const azureAPIBaseURL = "https://management.azure.com" -var extensionPorts = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "7001", - portainer.OAuthAuthenticationExtension: "7002", - portainer.RBACExtension: "7003", -} - type ( - // ProxyFactory is a factory to create reverse proxies to Docker endpoints and extensions + // ProxyFactory is a factory to create reverse proxies ProxyFactory struct { - resourceControlService portainer.ResourceControlService - userService portainer.UserService - teamService portainer.TeamService - teamMembershipService portainer.TeamMembershipService - settingsService portainer.SettingsService - registryService portainer.RegistryService - dockerHubService portainer.DockerHubService - signatureService portainer.DigitalSignatureService - reverseTunnelService portainer.ReverseTunnelService - extensionService portainer.ExtensionService - dockerClientFactory *docker.ClientFactory - } - - // ProxyFactoryParameters is used to create a new ProxyFactory - ProxyFactoryParameters struct { - ResourceControlService portainer.ResourceControlService - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - SettingsService portainer.SettingsService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - SignatureService portainer.DigitalSignatureService - ReverseTunnelService portainer.ReverseTunnelService - ExtensionService portainer.ExtensionService - DockerClientFactory *docker.ClientFactory + dataStore portainer.DataStore + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + dockerClientFactory *docker.ClientFactory + kubernetesClientFactory *cli.ClientFactory + kubernetesTokenCacheManager *kubernetes.TokenCacheManager } ) // NewProxyFactory returns a pointer to a new instance of a ProxyFactory -func NewProxyFactory(parameters *ProxyFactoryParameters) *ProxyFactory { +func NewProxyFactory(dataStore portainer.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *docker.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager) *ProxyFactory { return &ProxyFactory{ - resourceControlService: parameters.ResourceControlService, - userService: parameters.UserService, - teamService: parameters.TeamService, - teamMembershipService: parameters.TeamMembershipService, - settingsService: parameters.SettingsService, - registryService: parameters.RegistryService, - dockerHubService: parameters.DockerHubService, - signatureService: parameters.SignatureService, - reverseTunnelService: parameters.ReverseTunnelService, - extensionService: parameters.ExtensionService, - dockerClientFactory: parameters.DockerClientFactory, + dataStore: dataStore, + signatureService: signatureService, + reverseTunnelService: tunnelService, + dockerClientFactory: clientFactory, + kubernetesClientFactory: kubernetesClientFactory, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, } } -// BuildExtensionURL returns the URL to an extension server -func BuildExtensionURL(extensionID portainer.ExtensionID) string { - return fmt.Sprintf("http://%s:%s", portainer.ExtensionServer, extensionPorts[extensionID]) -} - -// NewExtensionProxy returns a new HTTP proxy to an extension server -func (factory *ProxyFactory) NewExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { - address := "http://" + portainer.ExtensionServer + ":" + extensionPorts[extensionID] - - extensionURL, err := url.Parse(address) - if err != nil { - return nil, err - } - - extensionURL.Scheme = "http" - proxy := httputil.NewSingleHostReverseProxy(extensionURL) - return proxy, nil -} - // NewLegacyExtensionProxy returns a new HTTP proxy to a legacy extension server (Storidge) func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (http.Handler, error) { extensionURL, err := url.Parse(extensionAPIURL) @@ -103,6 +56,8 @@ func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (htt switch endpoint.Type { case portainer.AzureEnvironment: return newAzureProxy(endpoint) + case portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.KubernetesLocalEnvironment: + return factory.newKubernetesProxy(endpoint) } return factory.newDockerProxy(endpoint) diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go new file mode 100644 index 000000000..2cb09dc62 --- /dev/null +++ b/api/http/proxy/factory/kubernetes.go @@ -0,0 +1,109 @@ +package factory + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" +) + +func (factory *ProxyFactory) newKubernetesProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + switch endpoint.Type { + case portainer.KubernetesLocalEnvironment: + return factory.newKubernetesLocalProxy(endpoint) + case portainer.EdgeAgentOnKubernetesEnvironment: + return factory.newKubernetesEdgeHTTPProxy(endpoint) + } + + return factory.newKubernetesAgentHTTPSProxy(endpoint) +} + +func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + remoteURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.CreateTokenCache(int(endpoint.ID)) + tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, true) + if err != nil { + return nil, err + } + + transport, err := kubernetes.NewLocalTransport(tokenManager) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = transport + + return proxy, nil +} + +func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpoint.URL = fmt.Sprintf("http://localhost:%d", tunnel.Port) + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.CreateTokenCache(int(endpoint.ID)) + tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, false) + if err != nil { + return nil, err + } + + endpointURL.Scheme = "http" + proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) + proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint.ID, tokenManager) + + return proxy, nil +} + +func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + endpointURL := fmt.Sprintf("https://%s", endpoint.URL) + remoteURL, err := url.Parse(endpointURL) + if err != nil { + return nil, err + } + + remoteURL.Scheme = "https" + + kubecli, err := factory.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return nil, err + } + + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.CreateTokenCache(int(endpoint.ID)) + tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, false) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager) + + return proxy, nil +} diff --git a/api/http/proxy/factory/kubernetes/token.go b/api/http/proxy/factory/kubernetes/token.go new file mode 100644 index 000000000..bfcc145d8 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/token.go @@ -0,0 +1,79 @@ +package kubernetes + +import ( + "io/ioutil" + "sync" + + portainer "github.com/portainer/portainer/api" +) + +const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +type tokenManager struct { + tokenCache *tokenCache + kubecli portainer.KubeClient + dataStore portainer.DataStore + mutex sync.Mutex + adminToken string +} + +// NewTokenManager returns a pointer to a new instance of tokenManager. +// If the useLocalAdminToken parameter is set to true, it will search for the local admin service account +// and associate it to the manager. +func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore, cache *tokenCache, setLocalAdminToken bool) (*tokenManager, error) { + tokenManager := &tokenManager{ + tokenCache: cache, + kubecli: kubecli, + dataStore: dataStore, + mutex: sync.Mutex{}, + adminToken: "", + } + + if setLocalAdminToken { + token, err := ioutil.ReadFile(defaultServiceAccountTokenFile) + if err != nil { + return nil, err + } + + tokenManager.adminToken = string(token) + } + + return tokenManager, nil +} + +func (manager *tokenManager) getAdminServiceAccountToken() string { + return manager.adminToken +} + +func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, error) { + manager.mutex.Lock() + defer manager.mutex.Unlock() + + token, ok := manager.tokenCache.getToken(userID) + if !ok { + memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + return "", err + } + + teamIds := make([]int, 0) + for _, membership := range memberships { + teamIds = append(teamIds, int(membership.TeamID)) + } + + err = manager.kubecli.SetupUserServiceAccount(userID, teamIds) + if err != nil { + return "", err + } + + serviceAccountToken, err := manager.kubecli.GetServiceAccountBearerToken(userID) + if err != nil { + return "", err + } + + manager.tokenCache.addToken(userID, serviceAccountToken) + token = serviceAccountToken + } + + return token, nil +} diff --git a/api/http/proxy/factory/kubernetes/token_cache.go b/api/http/proxy/factory/kubernetes/token_cache.go new file mode 100644 index 000000000..552e6b3a1 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/token_cache.go @@ -0,0 +1,69 @@ +package kubernetes + +import ( + "strconv" + + "github.com/orcaman/concurrent-map" +) + +type ( + // TokenCacheManager represents a service used to manage multiple tokenCache objects. + TokenCacheManager struct { + tokenCaches cmap.ConcurrentMap + } + + tokenCache struct { + userTokenCache cmap.ConcurrentMap + } +) + +// NewTokenCacheManager returns a pointer to a new instance of TokenCacheManager +func NewTokenCacheManager() *TokenCacheManager { + return &TokenCacheManager{ + tokenCaches: cmap.New(), + } +} + +// CreateTokenCache will create a new tokenCache object, associate it to the manager map of caches +// and return a pointer to that tokenCache instance. +func (manager *TokenCacheManager) CreateTokenCache(endpointID int) *tokenCache { + tokenCache := newTokenCache() + + key := strconv.Itoa(endpointID) + manager.tokenCaches.Set(key, tokenCache) + + return tokenCache +} + +// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches. +func (manager *TokenCacheManager) RemoveUserFromCache(userID int) { + for cache := range manager.tokenCaches.IterBuffered() { + cache.Val.(*tokenCache).removeToken(userID) + } +} + +func newTokenCache() *tokenCache { + return &tokenCache{ + userTokenCache: cmap.New(), + } +} + +func (cache *tokenCache) getToken(userID int) (string, bool) { + key := strconv.Itoa(userID) + token, ok := cache.userTokenCache.Get(key) + if ok { + return token.(string), true + } + + return "", false +} + +func (cache *tokenCache) addToken(userID int, token string) { + key := strconv.Itoa(userID) + cache.userTokenCache.Set(key, token) +} + +func (cache *tokenCache) removeToken(userID int) { + key := strconv.Itoa(userID) + cache.userTokenCache.Remove(key) +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go new file mode 100644 index 000000000..4fbacf590 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -0,0 +1,156 @@ +package kubernetes + +import ( + "crypto/tls" + "fmt" + "net/http" + + "github.com/portainer/portainer/api/http/security" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" +) + +type ( + localTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + } + + agentTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + signatureService portainer.DigitalSignatureService + } + + edgeTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + reverseTunnelService portainer.ReverseTunnelService + endpointIdentifier portainer.EndpointID + } +) + +// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API +func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) { + config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) + if err != nil { + return nil, err + } + + transport := &localTransport{ + httpTransport: &http.Transport{ + TLSClientConfig: config, + }, + tokenManager: tokenManager, + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + var token string + if tokenData.Role == portainer.AdministratorRole { + token = transport.tokenManager.getAdminServiceAccountToken() + } else { + token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) + if err != nil { + return nil, err + } + } + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + return transport.httpTransport.RoundTrip(request) +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport { + transport := &agentTransport{ + httpTransport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + tokenManager: tokenManager, + signatureService: signatureService, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + var token string + if tokenData.Role == portainer.AdministratorRole { + token = transport.tokenManager.getAdminServiceAccountToken() + } else { + token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) + if err != nil { + return nil, err + } + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + + return transport.httpTransport.RoundTrip(request) +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent +func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport { + transport := &edgeTransport{ + httpTransport: &http.Transport{}, + tokenManager: tokenManager, + reverseTunnelService: reverseTunnelService, + endpointIdentifier: endpointIdentifier, + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + var token string + if tokenData.Role == portainer.AdministratorRole { + token = transport.tokenManager.getAdminServiceAccountToken() + } else { + token, err = transport.tokenManager.getUserServiceAccountToken(int(tokenData.ID)) + if err != nil { + return nil, err + } + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + response, err := transport.httpTransport.RoundTrip(request) + + if err == nil { + transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier) + } else { + transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier) + } + + return response, err +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index fa27f6399..e539d89c2 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -2,10 +2,13 @@ package proxy import ( "net/http" - "strconv" - "github.com/orcaman/concurrent-map" - "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + + cmap "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer/api/kubernetes/cli" + + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/proxy/factory" ) @@ -17,47 +20,16 @@ type ( Manager struct { proxyFactory *factory.ProxyFactory endpointProxies cmap.ConcurrentMap - extensionProxies cmap.ConcurrentMap legacyExtensionProxies cmap.ConcurrentMap } - - // ManagerParams represents the required parameters to create a new Manager instance. - ManagerParams struct { - ResourceControlService portainer.ResourceControlService - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - SettingsService portainer.SettingsService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - SignatureService portainer.DigitalSignatureService - ReverseTunnelService portainer.ReverseTunnelService - ExtensionService portainer.ExtensionService - DockerClientFactory *docker.ClientFactory - } ) // NewManager initializes a new proxy Service -func NewManager(parameters *ManagerParams) *Manager { - proxyFactoryParameters := &factory.ProxyFactoryParameters{ - ResourceControlService: parameters.ResourceControlService, - UserService: parameters.UserService, - TeamService: parameters.TeamService, - TeamMembershipService: parameters.TeamMembershipService, - SettingsService: parameters.SettingsService, - RegistryService: parameters.RegistryService, - DockerHubService: parameters.DockerHubService, - SignatureService: parameters.SignatureService, - ReverseTunnelService: parameters.ReverseTunnelService, - ExtensionService: parameters.ExtensionService, - DockerClientFactory: parameters.DockerClientFactory, - } - +func NewManager(dataStore portainer.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *docker.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager) *Manager { return &Manager{ endpointProxies: cmap.New(), - extensionProxies: cmap.New(), legacyExtensionProxies: cmap.New(), - proxyFactory: factory.NewProxyFactory(proxyFactoryParameters), + proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager), } } @@ -88,38 +60,6 @@ func (manager *Manager) DeleteEndpointProxy(endpoint *portainer.Endpoint) { manager.endpointProxies.Remove(string(endpoint.ID)) } -// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and -// registers it in the extension map associated to the specified extension identifier -func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { - proxy, err := manager.proxyFactory.NewExtensionProxy(extensionID) - if err != nil { - return nil, err - } - - manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy) - return proxy, nil -} - -// GetExtensionProxy returns an extension proxy associated to an extension identifier -func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) http.Handler { - proxy, ok := manager.extensionProxies.Get(strconv.Itoa(int(extensionID))) - if !ok { - return nil - } - - return proxy.(http.Handler) -} - -// GetExtensionURL retrieves the URL of an extension running locally based on the extension port table -func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string { - return factory.BuildExtensionURL(extensionID) -} - -// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier -func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) { - manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) -} - // CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { proxy, err := manager.proxyFactory.NewLegacyExtensionProxy(extensionAPIURL) diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 5bcfe0c72..ac55555d3 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -2,37 +2,20 @@ package security import ( "errors" + "net/http" + "strings" httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" - - "net/http" - "strings" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + httperrors "github.com/portainer/portainer/api/http/errors" ) type ( // RequestBouncer represents an entity that manages API request accesses RequestBouncer struct { - jwtService portainer.JWTService - userService portainer.UserService - teamMembershipService portainer.TeamMembershipService - endpointService portainer.EndpointService - endpointGroupService portainer.EndpointGroupService - extensionService portainer.ExtensionService - rbacExtensionClient *rbacExtensionClient - authDisabled bool - } - - // RequestBouncerParams represents the required parameters to create a new RequestBouncer instance. - RequestBouncerParams struct { - JWTService portainer.JWTService - UserService portainer.UserService - TeamMembershipService portainer.TeamMembershipService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - ExtensionService portainer.ExtensionService - RBACExtensionURL string - AuthDisabled bool + dataStore portainer.DataStore + jwtService portainer.JWTService } // RestrictedRequestContext is a data structure containing information @@ -46,16 +29,10 @@ type ( ) // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(parameters *RequestBouncerParams) *RequestBouncer { +func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTService) *RequestBouncer { return &RequestBouncer{ - jwtService: parameters.JWTService, - userService: parameters.UserService, - teamMembershipService: parameters.TeamMembershipService, - endpointService: parameters.EndpointService, - endpointGroupService: parameters.EndpointGroupService, - extensionService: parameters.ExtensionService, - rbacExtensionClient: newRBACExtensionClient(parameters.RBACExtensionURL), - authDisabled: parameters.AuthDisabled, + dataStore: dataStore, + jwtService: jwtService, } } @@ -68,8 +45,7 @@ func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { // AdminAccess defines a security check for API endpoints that require an authorization check. // Authentication is required to access these endpoints. -// If the RBAC extension is enabled, authorizations are required to use these endpoints. -// If the RBAC extension is not enabled, the administrator role is required to use these endpoints. +// The administrator role is required to use these endpoints. // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to inside the API operation for extra authorization validation // and resource filtering. @@ -82,8 +58,6 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { // RestrictedAccess defines a security check for restricted API endpoints. // Authentication is required to access these endpoints. -// If the RBAC extension is enabled, authorizations are required to use these endpoints. -// If the RBAC extension is not enabled, access is granted to any authenticated user. // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to inside the API operation for extra authorization validation // and resource filtering. @@ -107,11 +81,9 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler // AuthorizedEndpointOperation retrieves the JWT token from the request context and verifies // that the user can access the specified endpoint. -// If the RBAC extension is enabled and the authorizationCheck flag is set, -// it will also validate that the user can execute the specified operation. // An error is returned when access to the endpoint is denied or if the user do not have the required // authorization to execute the operation. -func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint, authorizationCheck bool) error { +func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { tokenData, err := RetrieveTokenData(r) if err != nil { return err @@ -121,25 +93,18 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return nil } - memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return err } - group, err := bouncer.endpointGroupService.EndpointGroup(endpoint.GroupID) + group, err := bouncer.dataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { return err } if !authorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - return portainer.ErrEndpointAccessDenied - } - - if authorizationCheck { - err = bouncer.checkEndpointOperationAuthorization(r, endpoint) - if err != nil { - return portainer.ErrAuthorizationRequired - } + return httperrors.ErrEndpointAccessDenied } return nil @@ -147,7 +112,7 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp // 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 { + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { return errors.New("Invalid endpoint type") } @@ -163,38 +128,6 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, return nil } -func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Request, endpoint *portainer.Endpoint) error { - tokenData, err := RetrieveTokenData(r) - if err != nil { - return err - } - - if tokenData.Role == portainer.AdministratorRole { - return nil - } - - extension, err := bouncer.extensionService.Extension(portainer.RBACExtension) - if err == portainer.ErrObjectNotFound { - return nil - } else if err != nil { - return err - } - - user, err := bouncer.userService.User(tokenData.ID) - if err != nil { - return err - } - - apiOperation := &portainer.APIOperationAuthorizationRequest{ - Path: r.URL.String(), - Method: r.Method, - Authorizations: user.EndpointAuthorizations[endpoint.ID], - } - - bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey) - return bouncer.rbacExtensionClient.checkAuthorization(apiOperation) -} - // RegistryAccess retrieves the JWT token from the request context and verifies // that the user can access the specified registry. // An error is returned when access is denied. @@ -208,13 +141,13 @@ func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portain return nil } - memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return err } if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) { - return portainer.ErrEndpointAccessDenied + return httperrors.ErrEndpointAccessDenied } return nil @@ -227,15 +160,14 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler } // mwCheckPortainerAuthorizations will verify that the user has the required authorization to access -// a specific API endpoint. It will leverage the RBAC extension authorization validation if the extension -// is enabled. -// If the administratorOnly flag is specified and the RBAC extension is not enabled, this will prevent non-admin +// a specific API endpoint. +// If the administratorOnly flag is specified, this will prevent non-admin // users from accessing the endpoint. func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, administratorOnly bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) return } @@ -244,42 +176,20 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, return } - extension, err := bouncer.extensionService.Extension(portainer.RBACExtension) - if err == portainer.ErrObjectNotFound { - if administratorOnly { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrUnauthorized) - return - } - - next.ServeHTTP(w, r) - return - } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err) + if administratorOnly { + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) return } - user, err := bouncer.userService.User(tokenData.ID) - if err != nil && err == portainer.ErrObjectNotFound { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) + _, err = bouncer.dataStore.User().User(tokenData.ID) + if err != nil && err == bolterrors.ErrObjectNotFound { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) return } else if err != nil { httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) return } - apiOperation := &portainer.APIOperationAuthorizationRequest{ - Path: r.URL.String(), - Method: r.Method, - Authorizations: user.PortainerAuthorizations, - } - - bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey) - err = bouncer.rbacExtensionClient.checkAuthorization(apiOperation) - if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrAuthorizationRequired) - return - } - next.ServeHTTP(w, r) }) } @@ -290,7 +200,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrResourceAccessDenied) return } @@ -309,44 +219,38 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var tokenData *portainer.TokenData - if !bouncer.authDisabled { - var token string + var token string - // Optionally, token might be set via the "token" query parameter. - // For example, in websocket requests - token = r.URL.Query().Get("token") + // Optionally, token might be set via the "token" query parameter. + // For example, in websocket requests + token = r.URL.Query().Get("token") - // Get token from the Authorization header - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } + // Get token from the Authorization header + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } - if token == "" { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) - return - } + if token == "" { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) + return + } - var err error - tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) - if err != nil { - httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) - return - } + var err error + tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) + return + } - _, err = bouncer.userService.User(tokenData.ID) - if err != nil && err == portainer.ErrObjectNotFound { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) - return - } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) - return - } - } else { - tokenData = &portainer.TokenData{ - Role: portainer.AdministratorRole, - } + _, err = bouncer.dataStore.User().User(tokenData.ID) + if err != nil && err == bolterrors.ErrObjectNotFound { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) + return + } else if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err) + return } ctx := storeTokenData(r, tokenData) @@ -372,7 +276,7 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User if userRole != portainer.AdministratorRole { requestContext.IsAdmin = false - memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(userID) + memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(userID) if err != nil { return nil, err } @@ -390,3 +294,22 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User return requestContext, nil } + +// EdgeComputeOperation defines a restriced edge compute operation. +// Use of this operation will only be authorized if edgeCompute is enabled in settings +func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + settings, err := bouncer.dataStore.Settings().Settings() + if err != nil { + httperror.WriteError(w, http.StatusServiceUnavailable, "Unable to retrieve settings", err) + return + } + + if !settings.EnableEdgeComputeFeatures { + httperror.WriteError(w, http.StatusServiceUnavailable, "Edge compute features are disabled", errors.New("Edge compute features are disabled")) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/http/security/context.go b/api/http/security/context.go index 58daaa9e3..8350fa56f 100644 --- a/api/http/security/context.go +++ b/api/http/security/context.go @@ -2,6 +2,7 @@ package security import ( "context" + "errors" "net/http" "github.com/portainer/portainer/api" @@ -25,7 +26,7 @@ func storeTokenData(request *http.Request, tokenData *portainer.TokenData) conte func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { contextData := request.Context().Value(contextAuthenticationKey) if contextData == nil { - return nil, portainer.ErrMissingContextData + return nil, errors.New("Unable to find JWT data in request context") } tokenData := contextData.(*portainer.TokenData) @@ -42,7 +43,7 @@ func storeRestrictedRequestContext(request *http.Request, requestContext *Restri func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) { contextData := request.Context().Value(contextRestrictedRequest) if contextData == nil { - return nil, portainer.ErrMissingSecurityContext + return nil, errors.New("Unable to find security details in request context") } requestContext := contextData.(*RestrictedRequestContext) diff --git a/api/http/security/errors.go b/api/http/security/errors.go new file mode 100644 index 000000000..40193b38c --- /dev/null +++ b/api/http/security/errors.go @@ -0,0 +1,7 @@ +package security + +import "errors" + +var ( + ErrAuthorizationRequired = errors.New("Authorization required for this operation") +) diff --git a/api/http/security/filter.go b/api/http/security/filter.go index ba7872c39..1716b043e 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -79,24 +79,6 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques return filteredRegistries } -// FilterTemplates filters templates based on the user role. -// Non-administrator template do not have access to templates where the AdministratorOnly flag is set to true. -func FilterTemplates(templates []portainer.Template, context *RestrictedRequestContext) []portainer.Template { - filteredTemplates := templates - - if !context.IsAdmin { - filteredTemplates = make([]portainer.Template, 0) - - for _, template := range templates { - if !template.AdministratorOnly { - filteredTemplates = append(filteredTemplates, template) - } - } - } - - return filteredTemplates -} - // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go index e8cc7ae5c..65eea5d4a 100644 --- a/api/http/security/rate_limiter.go +++ b/api/http/security/rate_limiter.go @@ -7,7 +7,7 @@ import ( "github.com/g07cha/defender" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" ) // RateLimiter represents an entity that manages request rate limiting @@ -30,7 +30,7 @@ func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := StripAddrPort(r.RemoteAddr) if banned := limiter.Inc(ip); banned == true { - httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) + httperror.WriteError(w, http.StatusForbidden, "Access denied", errors.ErrResourceAccessDenied) return } next.ServeHTTP(w, r) diff --git a/api/http/security/rbac.go b/api/http/security/rbac.go deleted file mode 100644 index 08366cb72..000000000 --- a/api/http/security/rbac.go +++ /dev/null @@ -1,59 +0,0 @@ -package security - -import ( - "encoding/json" - "net/http" - "time" - - portainer "github.com/portainer/portainer/api" -) - -const ( - defaultHTTPTimeout = 5 -) - -type rbacExtensionClient struct { - httpClient *http.Client - extensionURL string - licenseKey string -} - -func newRBACExtensionClient(extensionURL string) *rbacExtensionClient { - return &rbacExtensionClient{ - extensionURL: extensionURL, - httpClient: &http.Client{ - Timeout: time.Second * time.Duration(defaultHTTPTimeout), - }, - } -} - -func (client *rbacExtensionClient) setLicenseKey(licenseKey string) { - client.licenseKey = licenseKey -} - -func (client *rbacExtensionClient) checkAuthorization(authRequest *portainer.APIOperationAuthorizationRequest) error { - encodedAuthRequest, err := json.Marshal(authRequest) - if err != nil { - return err - } - - req, err := http.NewRequest("GET", client.extensionURL+"/authorized_operation", nil) - if err != nil { - return err - } - - req.Header.Set("X-RBAC-AuthorizationRequest", string(encodedAuthRequest)) - req.Header.Set("X-PortainerExtension-License", client.licenseKey) - - resp, err := client.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent { - return portainer.ErrAuthorizationRequired - } - - return nil -} diff --git a/api/http/server.go b/api/http/server.go index f1c98ee5f..8f83529f1 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -1,30 +1,30 @@ package http import ( + "net/http" + "path/filepath" "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" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/handler" "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/customtemplates" "github.com/portainer/portainer/api/http/handler/dockerhub" + "github.com/portainer/portainer/api/http/handler/edgegroups" + "github.com/portainer/portainer/api/http/handler/edgejobs" + "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/endpointgroups" "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" - "github.com/portainer/portainer/api/http/handler/extensions" "github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" - "github.com/portainer/portainer/api/http/handler/schedules" + "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" @@ -37,281 +37,170 @@ import ( "github.com/portainer/portainer/api/http/handler/webhooks" "github.com/portainer/portainer/api/http/handler/websocket" "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" - - "net/http" - "path/filepath" + "github.com/portainer/portainer/api/kubernetes/cli" ) // 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 - EdgeGroupService portainer.EdgeGroupService - EdgeStackService portainer.EdgeStackService - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - EndpointRelationService portainer.EndpointRelationService + SnapshotService portainer.SnapshotService FileService portainer.FileService + DataStore portainer.DataStore 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 + OAuthService portainer.OAuthService 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 + KubernetesClientFactory *cli.ClientFactory + KubernetesDeployer portainer.KubernetesDeployer } // Start starts the HTTP server func (server *Server) Start() error { - proxyManagerParameters := &proxy.ManagerParams{ - ResourceControlService: server.ResourceControlService, - UserService: server.UserService, - TeamService: server.TeamService, - TeamMembershipService: server.TeamMembershipService, - SettingsService: server.SettingsService, - RegistryService: server.RegistryService, - DockerHubService: server.DockerHubService, - SignatureService: server.SignatureService, - ReverseTunnelService: server.ReverseTunnelService, - ExtensionService: server.ExtensionService, - DockerClientFactory: server.DockerClientFactory, - } - proxyManager := proxy.NewManager(proxyManagerParameters) + kubernetesTokenCacheManager := kubernetes.NewTokenCacheManager() + proxyManager := proxy.NewManager(server.DataStore, server.SignatureService, server.ReverseTunnelService, server.DockerClientFactory, server.KubernetesClientFactory, kubernetesTokenCacheManager) - authorizationServiceParameters := &portainer.AuthorizationServiceParameters{ - EndpointService: server.EndpointService, - EndpointGroupService: server.EndpointGroupService, - RegistryService: server.RegistryService, - RoleService: server.RoleService, - TeamMembershipService: server.TeamMembershipService, - UserService: server.UserService, - } - authorizationService := portainer.NewAuthorizationService(authorizationServiceParameters) - - requestBouncerParameters := &security.RequestBouncerParams{ - JWTService: server.JWTService, - UserService: server.UserService, - TeamMembershipService: server.TeamMembershipService, - EndpointService: server.EndpointService, - EndpointGroupService: server.EndpointGroupService, - ExtensionService: server.ExtensionService, - RBACExtensionURL: proxyManager.GetExtensionURL(portainer.RBACExtension), - AuthDisabled: server.AuthDisabled, - } - requestBouncer := security.NewRequestBouncer(requestBouncerParameters) + requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled) - authHandler.UserService = server.UserService + var authHandler = auth.NewHandler(requestBouncer, rateLimiter) + authHandler.DataStore = server.DataStore authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService - authHandler.SettingsService = server.SettingsService - authHandler.TeamService = server.TeamService - authHandler.TeamMembershipService = server.TeamMembershipService - authHandler.ExtensionService = server.ExtensionService - authHandler.EndpointService = server.EndpointService - authHandler.EndpointGroupService = server.EndpointGroupService - authHandler.RoleService = server.RoleService authHandler.ProxyManager = proxyManager - authHandler.AuthorizationService = authorizationService + authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager + authHandler.OAuthService = server.OAuthService var roleHandler = roles.NewHandler(requestBouncer) - roleHandler.RoleService = server.RoleService + roleHandler.DataStore = server.DataStore + + var customTemplatesHandler = customtemplates.NewHandler(requestBouncer) + customTemplatesHandler.DataStore = server.DataStore + customTemplatesHandler.FileService = server.FileService + customTemplatesHandler.GitService = server.GitService var dockerHubHandler = dockerhub.NewHandler(requestBouncer) - dockerHubHandler.DockerHubService = server.DockerHubService + dockerHubHandler.DataStore = server.DataStore 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 + edgeGroupsHandler.DataStore = server.DataStore + + var edgeJobsHandler = edgejobs.NewHandler(requestBouncer) + edgeJobsHandler.DataStore = server.DataStore + edgeJobsHandler.FileService = server.FileService + edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService 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.DataStore = server.DataStore edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) - edgeTemplatesHandler.SettingsService = server.SettingsService + edgeTemplatesHandler.DataStore = server.DataStore - 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 + var endpointHandler = endpoints.NewHandler(requestBouncer) + endpointHandler.DataStore = server.DataStore endpointHandler.FileService = server.FileService - endpointHandler.JobService = server.JobService + endpointHandler.ProxyManager = proxyManager + endpointHandler.SnapshotService = server.SnapshotService endpointHandler.ProxyManager = proxyManager endpointHandler.ReverseTunnelService = server.ReverseTunnelService - endpointHandler.SettingsService = server.SettingsService - endpointHandler.Snapshotter = server.Snapshotter - endpointHandler.TagService = server.TagService var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) - endpointEdgeHandler.EdgeStackService = server.EdgeStackService - endpointEdgeHandler.EndpointService = server.EndpointService + endpointEdgeHandler.DataStore = server.DataStore endpointEdgeHandler.FileService = server.FileService + endpointEdgeHandler.ReverseTunnelService = server.ReverseTunnelService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) - 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 + endpointGroupHandler.DataStore = server.DataStore var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) - endpointProxyHandler.EndpointService = server.EndpointService + endpointProxyHandler.DataStore = server.DataStore endpointProxyHandler.ProxyManager = proxyManager - endpointProxyHandler.SettingsService = server.SettingsService endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) var motdHandler = motd.NewHandler(requestBouncer) - var extensionHandler = extensions.NewHandler(requestBouncer) - extensionHandler.ExtensionService = server.ExtensionService - extensionHandler.ExtensionManager = server.ExtensionManager - extensionHandler.EndpointGroupService = server.EndpointGroupService - extensionHandler.EndpointService = server.EndpointService - extensionHandler.RegistryService = server.RegistryService - extensionHandler.AuthorizationService = authorizationService - var registryHandler = registries.NewHandler(requestBouncer) - registryHandler.RegistryService = server.RegistryService - registryHandler.ExtensionService = server.ExtensionService + registryHandler.DataStore = server.DataStore registryHandler.FileService = server.FileService registryHandler.ProxyManager = proxyManager var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) - resourceControlHandler.ResourceControlService = server.ResourceControlService - - var schedulesHandler = schedules.NewHandler(requestBouncer) - schedulesHandler.ScheduleService = server.ScheduleService - schedulesHandler.EndpointService = server.EndpointService - schedulesHandler.FileService = server.FileService - schedulesHandler.JobService = server.JobService - schedulesHandler.JobScheduler = server.JobScheduler - schedulesHandler.SettingsService = server.SettingsService - schedulesHandler.ReverseTunnelService = server.ReverseTunnelService + resourceControlHandler.DataStore = server.DataStore var settingsHandler = settings.NewHandler(requestBouncer) - settingsHandler.SettingsService = server.SettingsService - settingsHandler.LDAPService = server.LDAPService + settingsHandler.DataStore = server.DataStore settingsHandler.FileService = server.FileService - settingsHandler.JobScheduler = server.JobScheduler - settingsHandler.ScheduleService = server.ScheduleService - settingsHandler.RoleService = server.RoleService - settingsHandler.ExtensionService = server.ExtensionService - settingsHandler.AuthorizationService = authorizationService + settingsHandler.JWTService = server.JWTService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.SnapshotService = server.SnapshotService var stackHandler = stacks.NewHandler(requestBouncer) + stackHandler.DataStore = server.DataStore stackHandler.FileService = server.FileService - stackHandler.StackService = server.StackService - stackHandler.EndpointService = server.EndpointService - stackHandler.ResourceControlService = server.ResourceControlService stackHandler.SwarmStackManager = server.SwarmStackManager stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.KubernetesDeployer = server.KubernetesDeployer stackHandler.GitService = server.GitService - stackHandler.RegistryService = server.RegistryService - stackHandler.DockerHubService = server.DockerHubService - stackHandler.SettingsService = server.SettingsService - stackHandler.UserService = server.UserService - 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 + tagHandler.DataStore = server.DataStore var teamHandler = teams.NewHandler(requestBouncer) - teamHandler.TeamService = server.TeamService - teamHandler.TeamMembershipService = server.TeamMembershipService - teamHandler.AuthorizationService = authorizationService + teamHandler.DataStore = server.DataStore var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) - teamMembershipHandler.TeamMembershipService = server.TeamMembershipService - teamMembershipHandler.AuthorizationService = authorizationService + teamMembershipHandler.DataStore = server.DataStore var statusHandler = status.NewHandler(requestBouncer, server.Status) - var supportHandler = support.NewHandler(requestBouncer) - var templatesHandler = templates.NewHandler(requestBouncer) - templatesHandler.TemplateService = server.TemplateService - templatesHandler.SettingsService = server.SettingsService + templatesHandler.DataStore = server.DataStore + templatesHandler.FileService = server.FileService + templatesHandler.GitService = server.GitService var uploadHandler = upload.NewHandler(requestBouncer) uploadHandler.FileService = server.FileService var userHandler = users.NewHandler(requestBouncer, rateLimiter) - userHandler.UserService = server.UserService - userHandler.TeamService = server.TeamService - userHandler.TeamMembershipService = server.TeamMembershipService + userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService - userHandler.ResourceControlService = server.ResourceControlService - userHandler.SettingsService = server.SettingsService - userHandler.AuthorizationService = authorizationService var websocketHandler = websocket.NewHandler(requestBouncer) - websocketHandler.EndpointService = server.EndpointService + websocketHandler.DataStore = server.DataStore websocketHandler.SignatureService = server.SignatureService websocketHandler.ReverseTunnelService = server.ReverseTunnelService + websocketHandler.KubernetesClientFactory = server.KubernetesClientFactory var webhookHandler = webhooks.NewHandler(requestBouncer) - webhookHandler.WebhookService = server.WebhookService - webhookHandler.EndpointService = server.EndpointService + webhookHandler.DataStore = server.DataStore webhookHandler.DockerClientFactory = server.DockerClientFactory server.Handler = &handler.Handler{ RoleHandler: roleHandler, AuthHandler: authHandler, + CustomTemplatesHandler: customTemplatesHandler, DockerHubHandler: dockerHubHandler, EdgeGroupsHandler: edgeGroupsHandler, + EdgeJobsHandler: edgeJobsHandler, EdgeStacksHandler: edgeStacksHandler, EdgeTemplatesHandler: edgeTemplatesHandler, EndpointGroupHandler: endpointGroupHandler, @@ -320,13 +209,11 @@ func (server *Server) Start() error { EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, MOTDHandler: motdHandler, - ExtensionHandler: extensionHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, StatusHandler: statusHandler, StackHandler: stackHandler, - SupportHandler: supportHandler, TagHandler: tagHandler, TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, @@ -335,11 +222,16 @@ func (server *Server) Start() error { UserHandler: userHandler, WebSocketHandler: websocketHandler, WebhookHandler: webhookHandler, - SchedulesHanlder: schedulesHandler, + } + + httpServer := &http.Server{ + Addr: server.BindAddress, + Handler: server.Handler, } if server.SSL { - return http.ListenAndServeTLS(server.BindAddress, server.SSLCert, server.SSLKey, server.Handler) + httpServer.TLSConfig = crypto.CreateServerTLSConfiguration() + return httpServer.ListenAndServeTLS(server.SSLCert, server.SSLKey) } - return http.ListenAndServe(server.BindAddress, server.Handler) + return httpServer.ListenAndServe() } diff --git a/api/access_control.go b/api/internal/authorization/access_control.go similarity index 54% rename from api/access_control.go rename to api/internal/authorization/access_control.go index 7c767ce03..0e2d91ab7 100644 --- a/api/access_control.go +++ b/api/internal/authorization/access_control.go @@ -1,19 +1,25 @@ -package portainer +package authorization + +import ( + "strconv" + + "github.com/portainer/portainer/api" +) // NewPrivateResourceControl will create a new private resource control associated to the resource specified by the // identifier and type parameters. It automatically assigns it to the user specified by the userID parameter. -func NewPrivateResourceControl(resourceIdentifier string, resourceType ResourceControlType, userID UserID) *ResourceControl { - return &ResourceControl{ +func NewPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) *portainer.ResourceControl { + return &portainer.ResourceControl{ Type: resourceType, ResourceID: resourceIdentifier, SubResourceIDs: []string{}, - UserAccesses: []UserResourceAccess{ + UserAccesses: []portainer.UserResourceAccess{ { UserID: userID, - AccessLevel: ReadWriteAccessLevel, + AccessLevel: portainer.ReadWriteAccessLevel, }, }, - TeamAccesses: []TeamResourceAccess{}, + TeamAccesses: []portainer.TeamResourceAccess{}, AdministratorsOnly: false, Public: false, System: false, @@ -22,13 +28,13 @@ func NewPrivateResourceControl(resourceIdentifier string, resourceType ResourceC // NewSystemResourceControl will create a new public resource control with the System flag set to true. // These kind of resource control are not persisted and are created on the fly by the Portainer API. -func NewSystemResourceControl(resourceIdentifier string, resourceType ResourceControlType) *ResourceControl { - return &ResourceControl{ +func NewSystemResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl { + return &portainer.ResourceControl{ Type: resourceType, ResourceID: resourceIdentifier, SubResourceIDs: []string{}, - UserAccesses: []UserResourceAccess{}, - TeamAccesses: []TeamResourceAccess{}, + UserAccesses: []portainer.UserResourceAccess{}, + TeamAccesses: []portainer.TeamResourceAccess{}, AdministratorsOnly: false, Public: true, System: true, @@ -36,13 +42,13 @@ func NewSystemResourceControl(resourceIdentifier string, resourceType ResourceCo } // NewPublicResourceControl will create a new public resource control. -func NewPublicResourceControl(resourceIdentifier string, resourceType ResourceControlType) *ResourceControl { - return &ResourceControl{ +func NewPublicResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl { + return &portainer.ResourceControl{ Type: resourceType, ResourceID: resourceIdentifier, SubResourceIDs: []string{}, - UserAccesses: []UserResourceAccess{}, - TeamAccesses: []TeamResourceAccess{}, + UserAccesses: []portainer.UserResourceAccess{}, + TeamAccesses: []portainer.TeamResourceAccess{}, AdministratorsOnly: false, Public: true, System: false, @@ -50,29 +56,29 @@ func NewPublicResourceControl(resourceIdentifier string, resourceType ResourceCo } // NewRestrictedResourceControl will create a new resource control with user and team accesses restrictions. -func NewRestrictedResourceControl(resourceIdentifier string, resourceType ResourceControlType, userIDs []UserID, teamIDs []TeamID) *ResourceControl { - userAccesses := make([]UserResourceAccess, 0) - teamAccesses := make([]TeamResourceAccess, 0) +func NewRestrictedResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userIDs []portainer.UserID, teamIDs []portainer.TeamID) *portainer.ResourceControl { + userAccesses := make([]portainer.UserResourceAccess, 0) + teamAccesses := make([]portainer.TeamResourceAccess, 0) for _, id := range userIDs { - access := UserResourceAccess{ + access := portainer.UserResourceAccess{ UserID: id, - AccessLevel: ReadWriteAccessLevel, + AccessLevel: portainer.ReadWriteAccessLevel, } userAccesses = append(userAccesses, access) } for _, id := range teamIDs { - access := TeamResourceAccess{ + access := portainer.TeamResourceAccess{ TeamID: id, - AccessLevel: ReadWriteAccessLevel, + AccessLevel: portainer.ReadWriteAccessLevel, } teamAccesses = append(teamAccesses, access) } - return &ResourceControl{ + return &portainer.ResourceControl{ Type: resourceType, ResourceID: resourceIdentifier, SubResourceIDs: []string{}, @@ -86,10 +92,10 @@ func NewRestrictedResourceControl(resourceIdentifier string, resourceType Resour // DecorateStacks will iterate through a list of stacks, check for an associated resource control for each // stack and decorate the stack element if a resource control is found. -func DecorateStacks(stacks []Stack, resourceControls []ResourceControl) []Stack { +func DecorateStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl) []portainer.Stack { for idx, stack := range stacks { - resourceControl := GetResourceControlByResourceIDAndType(stack.Name, StackResourceControl, resourceControls) + resourceControl := GetResourceControlByResourceIDAndType(stack.Name, portainer.StackResourceControl, resourceControls) if resourceControl != nil { stacks[idx].ResourceControl = resourceControl } @@ -98,17 +104,25 @@ func DecorateStacks(stacks []Stack, resourceControls []ResourceControl) []Stack return stacks } +// DecorateCustomTemplates will iterate through a list of custom templates, check for an associated resource control for each +// template and decorate the template element if a resource control is found. +func DecorateCustomTemplates(templates []portainer.CustomTemplate, resourceControls []portainer.ResourceControl) []portainer.CustomTemplate { + for idx, template := range templates { + + resourceControl := GetResourceControlByResourceIDAndType(strconv.Itoa(int(template.ID)), portainer.CustomTemplateResourceControl, resourceControls) + if resourceControl != nil { + templates[idx].ResourceControl = resourceControl + } + } + + return templates +} + // FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks. -func FilterAuthorizedStacks(stacks []Stack, user *User, userTeamIDs []TeamID, rbacEnabled bool) []Stack { - authorizedStacks := make([]Stack, 0) +func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.Stack { + authorizedStacks := make([]portainer.Stack, 0) for _, stack := range stacks { - _, ok := user.EndpointAuthorizations[stack.EndpointID][EndpointResourcesAccess] - if rbacEnabled && ok { - authorizedStacks = append(authorizedStacks, stack) - continue - } - if stack.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, stack.ResourceControl) { authorizedStacks = append(authorizedStacks, stack) } @@ -117,9 +131,22 @@ func FilterAuthorizedStacks(stacks []Stack, user *User, userTeamIDs []TeamID, rb return authorizedStacks } +// FilterAuthorizedCustomTemplates returns a list of decorated custom templates filtered through resource control access checks. +func FilterAuthorizedCustomTemplates(customTemplates []portainer.CustomTemplate, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.CustomTemplate { + authorizedTemplates := make([]portainer.CustomTemplate, 0) + + for _, customTemplate := range customTemplates { + if customTemplate.CreatedByUserID == user.ID || (customTemplate.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, customTemplate.ResourceControl)) { + authorizedTemplates = append(authorizedTemplates, customTemplate) + } + } + + return authorizedTemplates +} + // UserCanAccessResource will valide that a user has permissions defined in the specified resource control // based on its identifier and the team(s) he is part of. -func UserCanAccessResource(userID UserID, userTeamIDs []TeamID, resourceControl *ResourceControl) bool { +func UserCanAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { for _, authorizedUserAccess := range resourceControl.UserAccesses { if userID == authorizedUserAccess.UserID { return true @@ -139,7 +166,7 @@ func UserCanAccessResource(userID UserID, userTeamIDs []TeamID, resourceControl // GetResourceControlByResourceIDAndType retrieves the first matching resource control in a set of resource controls // based on the specified id and resource type parameters. -func GetResourceControlByResourceIDAndType(resourceID string, resourceType ResourceControlType, resourceControls []ResourceControl) *ResourceControl { +func GetResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) *portainer.ResourceControl { for _, resourceControl := range resourceControls { if resourceID == resourceControl.ResourceID && resourceType == resourceControl.Type { return &resourceControl diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go new file mode 100644 index 000000000..31916cd31 --- /dev/null +++ b/api/internal/authorization/authorizations.go @@ -0,0 +1,599 @@ +package authorization + +import "github.com/portainer/portainer/api" + +// Service represents a service used to +// update authorizations associated to a user or team. +type Service struct { + dataStore portainer.DataStore +} + +// NewService returns a point to a new Service instance. +func NewService(dataStore portainer.DataStore) *Service { + return &Service{ + dataStore: dataStore, + } +} + +// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default endpoint authorizations +// associated to the endpoint administrator role. +func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Authorizations { + return map[portainer.Authorization]bool{ + portainer.OperationDockerContainerArchiveInfo: true, + portainer.OperationDockerContainerList: true, + portainer.OperationDockerContainerExport: true, + portainer.OperationDockerContainerChanges: true, + portainer.OperationDockerContainerInspect: true, + portainer.OperationDockerContainerTop: true, + portainer.OperationDockerContainerLogs: true, + portainer.OperationDockerContainerStats: true, + portainer.OperationDockerContainerAttachWebsocket: true, + portainer.OperationDockerContainerArchive: true, + portainer.OperationDockerContainerCreate: true, + portainer.OperationDockerContainerPrune: true, + portainer.OperationDockerContainerKill: true, + portainer.OperationDockerContainerPause: true, + portainer.OperationDockerContainerUnpause: true, + portainer.OperationDockerContainerRestart: true, + portainer.OperationDockerContainerStart: true, + portainer.OperationDockerContainerStop: true, + portainer.OperationDockerContainerWait: true, + portainer.OperationDockerContainerResize: true, + portainer.OperationDockerContainerAttach: true, + portainer.OperationDockerContainerExec: true, + portainer.OperationDockerContainerRename: true, + portainer.OperationDockerContainerUpdate: true, + portainer.OperationDockerContainerPutContainerArchive: true, + portainer.OperationDockerContainerDelete: true, + portainer.OperationDockerImageList: true, + portainer.OperationDockerImageSearch: true, + portainer.OperationDockerImageGetAll: true, + portainer.OperationDockerImageGet: true, + portainer.OperationDockerImageHistory: true, + portainer.OperationDockerImageInspect: true, + portainer.OperationDockerImageLoad: true, + portainer.OperationDockerImageCreate: true, + portainer.OperationDockerImagePrune: true, + portainer.OperationDockerImagePush: true, + portainer.OperationDockerImageTag: true, + portainer.OperationDockerImageDelete: true, + portainer.OperationDockerImageCommit: true, + portainer.OperationDockerImageBuild: true, + portainer.OperationDockerNetworkList: true, + portainer.OperationDockerNetworkInspect: true, + portainer.OperationDockerNetworkCreate: true, + portainer.OperationDockerNetworkConnect: true, + portainer.OperationDockerNetworkDisconnect: true, + portainer.OperationDockerNetworkPrune: true, + portainer.OperationDockerNetworkDelete: true, + portainer.OperationDockerVolumeList: true, + portainer.OperationDockerVolumeInspect: true, + portainer.OperationDockerVolumeCreate: true, + portainer.OperationDockerVolumePrune: true, + portainer.OperationDockerVolumeDelete: true, + portainer.OperationDockerExecInspect: true, + portainer.OperationDockerExecStart: true, + portainer.OperationDockerExecResize: true, + portainer.OperationDockerSwarmInspect: true, + portainer.OperationDockerSwarmUnlockKey: true, + portainer.OperationDockerSwarmInit: true, + portainer.OperationDockerSwarmJoin: true, + portainer.OperationDockerSwarmLeave: true, + portainer.OperationDockerSwarmUpdate: true, + portainer.OperationDockerSwarmUnlock: true, + portainer.OperationDockerNodeList: true, + portainer.OperationDockerNodeInspect: true, + portainer.OperationDockerNodeUpdate: true, + portainer.OperationDockerNodeDelete: true, + portainer.OperationDockerServiceList: true, + portainer.OperationDockerServiceInspect: true, + portainer.OperationDockerServiceLogs: true, + portainer.OperationDockerServiceCreate: true, + portainer.OperationDockerServiceUpdate: true, + portainer.OperationDockerServiceDelete: true, + portainer.OperationDockerSecretList: true, + portainer.OperationDockerSecretInspect: true, + portainer.OperationDockerSecretCreate: true, + portainer.OperationDockerSecretUpdate: true, + portainer.OperationDockerSecretDelete: true, + portainer.OperationDockerConfigList: true, + portainer.OperationDockerConfigInspect: true, + portainer.OperationDockerConfigCreate: true, + portainer.OperationDockerConfigUpdate: true, + portainer.OperationDockerConfigDelete: true, + portainer.OperationDockerTaskList: true, + portainer.OperationDockerTaskInspect: true, + portainer.OperationDockerTaskLogs: true, + portainer.OperationDockerPluginList: true, + portainer.OperationDockerPluginPrivileges: true, + portainer.OperationDockerPluginInspect: true, + portainer.OperationDockerPluginPull: true, + portainer.OperationDockerPluginCreate: true, + portainer.OperationDockerPluginEnable: true, + portainer.OperationDockerPluginDisable: true, + portainer.OperationDockerPluginPush: true, + portainer.OperationDockerPluginUpgrade: true, + portainer.OperationDockerPluginSet: true, + portainer.OperationDockerPluginDelete: true, + portainer.OperationDockerSessionStart: true, + portainer.OperationDockerDistributionInspect: true, + portainer.OperationDockerBuildPrune: true, + portainer.OperationDockerBuildCancel: true, + portainer.OperationDockerPing: true, + portainer.OperationDockerInfo: true, + portainer.OperationDockerVersion: true, + portainer.OperationDockerEvents: true, + portainer.OperationDockerSystem: true, + portainer.OperationDockerUndefined: true, + portainer.OperationDockerAgentPing: true, + portainer.OperationDockerAgentList: true, + portainer.OperationDockerAgentHostInfo: true, + portainer.OperationDockerAgentBrowseDelete: true, + portainer.OperationDockerAgentBrowseGet: true, + portainer.OperationDockerAgentBrowseList: true, + portainer.OperationDockerAgentBrowsePut: true, + portainer.OperationDockerAgentBrowseRename: true, + portainer.OperationDockerAgentUndefined: true, + portainer.OperationPortainerResourceControlCreate: true, + portainer.OperationPortainerResourceControlUpdate: true, + portainer.OperationPortainerStackList: true, + portainer.OperationPortainerStackInspect: true, + portainer.OperationPortainerStackFile: true, + portainer.OperationPortainerStackCreate: true, + portainer.OperationPortainerStackMigrate: true, + portainer.OperationPortainerStackUpdate: true, + portainer.OperationPortainerStackDelete: true, + portainer.OperationPortainerWebsocketExec: true, + portainer.OperationPortainerWebhookList: true, + portainer.OperationPortainerWebhookCreate: true, + portainer.OperationPortainerWebhookDelete: true, + portainer.OperationIntegrationStoridgeAdmin: true, + portainer.EndpointResourcesAccess: true, + } +} + +// DefaultEndpointAuthorizationsForHelpDeskRole returns the default endpoint authorizations +// associated to the helpdesk role. +func DefaultEndpointAuthorizationsForHelpDeskRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { + authorizations := map[portainer.Authorization]bool{ + portainer.OperationDockerContainerArchiveInfo: true, + portainer.OperationDockerContainerList: true, + portainer.OperationDockerContainerChanges: true, + portainer.OperationDockerContainerInspect: true, + portainer.OperationDockerContainerTop: true, + portainer.OperationDockerContainerLogs: true, + portainer.OperationDockerContainerStats: true, + portainer.OperationDockerImageList: true, + portainer.OperationDockerImageSearch: true, + portainer.OperationDockerImageGetAll: true, + portainer.OperationDockerImageGet: true, + portainer.OperationDockerImageHistory: true, + portainer.OperationDockerImageInspect: true, + portainer.OperationDockerNetworkList: true, + portainer.OperationDockerNetworkInspect: true, + portainer.OperationDockerVolumeList: true, + portainer.OperationDockerVolumeInspect: true, + portainer.OperationDockerSwarmInspect: true, + portainer.OperationDockerNodeList: true, + portainer.OperationDockerNodeInspect: true, + portainer.OperationDockerServiceList: true, + portainer.OperationDockerServiceInspect: true, + portainer.OperationDockerServiceLogs: true, + portainer.OperationDockerSecretList: true, + portainer.OperationDockerSecretInspect: true, + portainer.OperationDockerConfigList: true, + portainer.OperationDockerConfigInspect: true, + portainer.OperationDockerTaskList: true, + portainer.OperationDockerTaskInspect: true, + portainer.OperationDockerTaskLogs: true, + portainer.OperationDockerPluginList: true, + portainer.OperationDockerDistributionInspect: true, + portainer.OperationDockerPing: true, + portainer.OperationDockerInfo: true, + portainer.OperationDockerVersion: true, + portainer.OperationDockerEvents: true, + portainer.OperationDockerSystem: true, + portainer.OperationDockerAgentPing: true, + portainer.OperationDockerAgentList: true, + portainer.OperationDockerAgentHostInfo: true, + portainer.OperationPortainerStackList: true, + portainer.OperationPortainerStackInspect: true, + portainer.OperationPortainerStackFile: true, + portainer.OperationPortainerWebhookList: true, + portainer.EndpointResourcesAccess: true, + } + + if volumeBrowsingAuthorizations { + authorizations[portainer.OperationDockerAgentBrowseGet] = true + authorizations[portainer.OperationDockerAgentBrowseList] = true + } + + return authorizations +} + +// DefaultEndpointAuthorizationsForStandardUserRole returns the default endpoint authorizations +// associated to the standard user role. +func DefaultEndpointAuthorizationsForStandardUserRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { + authorizations := map[portainer.Authorization]bool{ + portainer.OperationDockerContainerArchiveInfo: true, + portainer.OperationDockerContainerList: true, + portainer.OperationDockerContainerExport: true, + portainer.OperationDockerContainerChanges: true, + portainer.OperationDockerContainerInspect: true, + portainer.OperationDockerContainerTop: true, + portainer.OperationDockerContainerLogs: true, + portainer.OperationDockerContainerStats: true, + portainer.OperationDockerContainerAttachWebsocket: true, + portainer.OperationDockerContainerArchive: true, + portainer.OperationDockerContainerCreate: true, + portainer.OperationDockerContainerKill: true, + portainer.OperationDockerContainerPause: true, + portainer.OperationDockerContainerUnpause: true, + portainer.OperationDockerContainerRestart: true, + portainer.OperationDockerContainerStart: true, + portainer.OperationDockerContainerStop: true, + portainer.OperationDockerContainerWait: true, + portainer.OperationDockerContainerResize: true, + portainer.OperationDockerContainerAttach: true, + portainer.OperationDockerContainerExec: true, + portainer.OperationDockerContainerRename: true, + portainer.OperationDockerContainerUpdate: true, + portainer.OperationDockerContainerPutContainerArchive: true, + portainer.OperationDockerContainerDelete: true, + portainer.OperationDockerImageList: true, + portainer.OperationDockerImageSearch: true, + portainer.OperationDockerImageGetAll: true, + portainer.OperationDockerImageGet: true, + portainer.OperationDockerImageHistory: true, + portainer.OperationDockerImageInspect: true, + portainer.OperationDockerImageLoad: true, + portainer.OperationDockerImageCreate: true, + portainer.OperationDockerImagePush: true, + portainer.OperationDockerImageTag: true, + portainer.OperationDockerImageDelete: true, + portainer.OperationDockerImageCommit: true, + portainer.OperationDockerImageBuild: true, + portainer.OperationDockerNetworkList: true, + portainer.OperationDockerNetworkInspect: true, + portainer.OperationDockerNetworkCreate: true, + portainer.OperationDockerNetworkConnect: true, + portainer.OperationDockerNetworkDisconnect: true, + portainer.OperationDockerNetworkDelete: true, + portainer.OperationDockerVolumeList: true, + portainer.OperationDockerVolumeInspect: true, + portainer.OperationDockerVolumeCreate: true, + portainer.OperationDockerVolumeDelete: true, + portainer.OperationDockerExecInspect: true, + portainer.OperationDockerExecStart: true, + portainer.OperationDockerExecResize: true, + portainer.OperationDockerSwarmInspect: true, + portainer.OperationDockerSwarmUnlockKey: true, + portainer.OperationDockerSwarmInit: true, + portainer.OperationDockerSwarmJoin: true, + portainer.OperationDockerSwarmLeave: true, + portainer.OperationDockerSwarmUpdate: true, + portainer.OperationDockerSwarmUnlock: true, + portainer.OperationDockerNodeList: true, + portainer.OperationDockerNodeInspect: true, + portainer.OperationDockerNodeUpdate: true, + portainer.OperationDockerNodeDelete: true, + portainer.OperationDockerServiceList: true, + portainer.OperationDockerServiceInspect: true, + portainer.OperationDockerServiceLogs: true, + portainer.OperationDockerServiceCreate: true, + portainer.OperationDockerServiceUpdate: true, + portainer.OperationDockerServiceDelete: true, + portainer.OperationDockerSecretList: true, + portainer.OperationDockerSecretInspect: true, + portainer.OperationDockerSecretCreate: true, + portainer.OperationDockerSecretUpdate: true, + portainer.OperationDockerSecretDelete: true, + portainer.OperationDockerConfigList: true, + portainer.OperationDockerConfigInspect: true, + portainer.OperationDockerConfigCreate: true, + portainer.OperationDockerConfigUpdate: true, + portainer.OperationDockerConfigDelete: true, + portainer.OperationDockerTaskList: true, + portainer.OperationDockerTaskInspect: true, + portainer.OperationDockerTaskLogs: true, + portainer.OperationDockerPluginList: true, + portainer.OperationDockerPluginPrivileges: true, + portainer.OperationDockerPluginInspect: true, + portainer.OperationDockerPluginPull: true, + portainer.OperationDockerPluginCreate: true, + portainer.OperationDockerPluginEnable: true, + portainer.OperationDockerPluginDisable: true, + portainer.OperationDockerPluginPush: true, + portainer.OperationDockerPluginUpgrade: true, + portainer.OperationDockerPluginSet: true, + portainer.OperationDockerPluginDelete: true, + portainer.OperationDockerSessionStart: true, + portainer.OperationDockerDistributionInspect: true, + portainer.OperationDockerBuildPrune: true, + portainer.OperationDockerBuildCancel: true, + portainer.OperationDockerPing: true, + portainer.OperationDockerInfo: true, + portainer.OperationDockerVersion: true, + portainer.OperationDockerEvents: true, + portainer.OperationDockerSystem: true, + portainer.OperationDockerUndefined: true, + portainer.OperationDockerAgentPing: true, + portainer.OperationDockerAgentList: true, + portainer.OperationDockerAgentHostInfo: true, + portainer.OperationDockerAgentUndefined: true, + portainer.OperationPortainerResourceControlUpdate: true, + portainer.OperationPortainerStackList: true, + portainer.OperationPortainerStackInspect: true, + portainer.OperationPortainerStackFile: true, + portainer.OperationPortainerStackCreate: true, + portainer.OperationPortainerStackMigrate: true, + portainer.OperationPortainerStackUpdate: true, + portainer.OperationPortainerStackDelete: true, + portainer.OperationPortainerWebsocketExec: true, + portainer.OperationPortainerWebhookList: true, + portainer.OperationPortainerWebhookCreate: true, + } + + if volumeBrowsingAuthorizations { + authorizations[portainer.OperationDockerAgentBrowseGet] = true + authorizations[portainer.OperationDockerAgentBrowseList] = true + authorizations[portainer.OperationDockerAgentBrowseDelete] = true + authorizations[portainer.OperationDockerAgentBrowsePut] = true + authorizations[portainer.OperationDockerAgentBrowseRename] = true + } + + return authorizations +} + +// DefaultEndpointAuthorizationsForReadOnlyUserRole returns the default endpoint authorizations +// associated to the readonly user role. +func DefaultEndpointAuthorizationsForReadOnlyUserRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { + authorizations := map[portainer.Authorization]bool{ + portainer.OperationDockerContainerArchiveInfo: true, + portainer.OperationDockerContainerList: true, + portainer.OperationDockerContainerChanges: true, + portainer.OperationDockerContainerInspect: true, + portainer.OperationDockerContainerTop: true, + portainer.OperationDockerContainerLogs: true, + portainer.OperationDockerContainerStats: true, + portainer.OperationDockerImageList: true, + portainer.OperationDockerImageSearch: true, + portainer.OperationDockerImageGetAll: true, + portainer.OperationDockerImageGet: true, + portainer.OperationDockerImageHistory: true, + portainer.OperationDockerImageInspect: true, + portainer.OperationDockerNetworkList: true, + portainer.OperationDockerNetworkInspect: true, + portainer.OperationDockerVolumeList: true, + portainer.OperationDockerVolumeInspect: true, + portainer.OperationDockerSwarmInspect: true, + portainer.OperationDockerNodeList: true, + portainer.OperationDockerNodeInspect: true, + portainer.OperationDockerServiceList: true, + portainer.OperationDockerServiceInspect: true, + portainer.OperationDockerServiceLogs: true, + portainer.OperationDockerSecretList: true, + portainer.OperationDockerSecretInspect: true, + portainer.OperationDockerConfigList: true, + portainer.OperationDockerConfigInspect: true, + portainer.OperationDockerTaskList: true, + portainer.OperationDockerTaskInspect: true, + portainer.OperationDockerTaskLogs: true, + portainer.OperationDockerPluginList: true, + portainer.OperationDockerDistributionInspect: true, + portainer.OperationDockerPing: true, + portainer.OperationDockerInfo: true, + portainer.OperationDockerVersion: true, + portainer.OperationDockerEvents: true, + portainer.OperationDockerSystem: true, + portainer.OperationDockerAgentPing: true, + portainer.OperationDockerAgentList: true, + portainer.OperationDockerAgentHostInfo: true, + portainer.OperationPortainerStackList: true, + portainer.OperationPortainerStackInspect: true, + portainer.OperationPortainerStackFile: true, + portainer.OperationPortainerWebhookList: true, + } + + if volumeBrowsingAuthorizations { + authorizations[portainer.OperationDockerAgentBrowseGet] = true + authorizations[portainer.OperationDockerAgentBrowseList] = true + } + + return authorizations +} + +// DefaultPortainerAuthorizations returns the default Portainer authorizations used by non-admin users. +func DefaultPortainerAuthorizations() portainer.Authorizations { + return map[portainer.Authorization]bool{ + portainer.OperationPortainerDockerHubInspect: true, + portainer.OperationPortainerEndpointGroupList: true, + portainer.OperationPortainerEndpointList: true, + portainer.OperationPortainerEndpointInspect: true, + portainer.OperationPortainerEndpointExtensionAdd: true, + portainer.OperationPortainerEndpointExtensionRemove: true, + portainer.OperationPortainerMOTD: true, + portainer.OperationPortainerRegistryList: true, + portainer.OperationPortainerRegistryInspect: true, + portainer.OperationPortainerTeamList: true, + portainer.OperationPortainerTemplateList: true, + portainer.OperationPortainerTemplateInspect: true, + portainer.OperationPortainerUserList: true, + portainer.OperationPortainerUserInspect: true, + portainer.OperationPortainerUserMemberships: true, + } +} + +// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. +func (service *Service) UpdateUsersAuthorizations() error { + users, err := service.dataStore.User().Users() + if err != nil { + return err + } + + for _, user := range users { + err := service.updateUserAuthorizations(user.ID) + if err != nil { + return err + } + } + + return nil +} + +func (service *Service) updateUserAuthorizations(userID portainer.UserID) error { + user, err := service.dataStore.User().User(userID) + if err != nil { + return err + } + + endpointAuthorizations, err := service.getAuthorizations(user) + if err != nil { + return err + } + + user.EndpointAuthorizations = endpointAuthorizations + + return service.dataStore.User().UpdateUser(userID, user) +} + +func (service *Service) getAuthorizations(user *portainer.User) (portainer.EndpointAuthorizations, error) { + endpointAuthorizations := portainer.EndpointAuthorizations{} + if user.Role == portainer.AdministratorRole { + return endpointAuthorizations, nil + } + + userMemberships, err := service.dataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return endpointAuthorizations, err + } + + endpoints, err := service.dataStore.Endpoint().Endpoints() + if err != nil { + return endpointAuthorizations, err + } + + endpointGroups, err := service.dataStore.EndpointGroup().EndpointGroups() + if err != nil { + return endpointAuthorizations, err + } + + roles, err := service.dataStore.Role().Roles() + if err != nil { + return endpointAuthorizations, err + } + + endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships) + + return endpointAuthorizations, nil +} + +func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations { + endpointAuthorizations := make(portainer.EndpointAuthorizations) + + groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{} + groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{} + for _, endpointGroup := range endpointGroups { + groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies + groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies + } + + for _, endpoint := range endpoints { + authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + continue + } + + authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies) + if len(authorizations) > 0 { + endpointAuthorizations[endpoint.ID] = authorizations + } + } + + return endpointAuthorizations +} + +func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { + policyRoles := make([]portainer.RoleID, 0) + + policy, ok := endpoint.UserAccessPolicies[user.ID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations { + policyRoles := make([]portainer.RoleID, 0) + + policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations { + policyRoles := make([]portainer.RoleID, 0) + + for _, membership := range memberships { + policy, ok := endpoint.TeamAccessPolicies[membership.TeamID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations { + policyRoles := make([]portainer.RoleID, 0) + + for _, membership := range memberships { + policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID] + if ok { + policyRoles = append(policyRoles, policy.RoleID) + } + } + + return getAuthorizationsFromRoles(policyRoles, roles) +} + +func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations { + var associatedRoles []portainer.Role + + for _, id := range roleIdentifiers { + for _, role := range roles { + if role.ID == id { + associatedRoles = append(associatedRoles, role) + break + } + } + } + + var authorizations portainer.Authorizations + highestPriority := 0 + for _, role := range associatedRoles { + if role.Priority > highestPriority { + highestPriority = role.Priority + authorizations = role.Authorizations + } + } + + return authorizations +} diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go new file mode 100644 index 000000000..0b0140acb --- /dev/null +++ b/api/internal/edge/edgegroup.go @@ -0,0 +1,59 @@ +package edge + +import ( + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/tag" +) + +// EdgeGroupRelatedEndpoints returns a list of endpoints related to this Edge group +func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup) []portainer.EndpointID { + if !edgeGroup.Dynamic { + return edgeGroup.Endpoints + } + + endpointIDs := []portainer.EndpointID{} + for _, endpoint := range endpoints { + if endpoint.Type != portainer.EdgeAgentOnDockerEnvironment && endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + continue + } + + var endpointGroup portainer.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 *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool { + if !edgeGroup.Dynamic { + for _, endpointID := range edgeGroup.Endpoints { + if endpoint.ID == endpointID { + return true + } + } + return false + } + + endpointTags := tag.Set(endpoint.TagIDs) + if endpointGroup.TagIDs != nil { + endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs)) + } + edgeGroupTags := tag.Set(edgeGroup.TagIDs) + + if edgeGroup.PartialMatch { + intersection := tag.Intersection(endpointTags, edgeGroupTags) + return len(intersection) != 0 + } + + return tag.Contains(edgeGroupTags, endpointTags) +} diff --git a/api/edgestack.go b/api/internal/edge/edgestack.go similarity index 56% rename from api/edgestack.go rename to api/internal/edge/edgestack.go index 7a3019c5d..6f4094e9d 100644 --- a/api/edgestack.go +++ b/api/internal/edge/edgestack.go @@ -1,13 +1,16 @@ -package portainer +package edge -import "errors" +import ( + "errors" + "github.com/portainer/portainer/api" +) // 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{} +func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup) ([]portainer.EndpointID, error) { + edgeStackEndpoints := []portainer.EndpointID{} for _, edgeGroupID := range edgeGroupIDs { - var edgeGroup *EdgeGroup + var edgeGroup *portainer.EdgeGroup for _, group := range edgeGroups { if group.ID == edgeGroupID { diff --git a/api/endpoint.go b/api/internal/edge/endpoint.go similarity index 58% rename from api/endpoint.go rename to api/internal/edge/endpoint.go index 661818da3..99d12bf60 100644 --- a/api/endpoint.go +++ b/api/internal/edge/endpoint.go @@ -1,8 +1,10 @@ -package portainer +package edge + +import "github.com/portainer/portainer/api" // 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{} +func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) []portainer.EdgeStackID { + relatedEdgeGroupsSet := map[portainer.EdgeGroupID]bool{} for _, edgeGroup := range edgeGroups { if edgeGroupRelatedToEndpoint(&edgeGroup, endpoint, endpointGroup) { @@ -10,7 +12,7 @@ func EndpointRelatedEdgeStacks(endpoint *Endpoint, endpointGroup *EndpointGroup, } } - relatedEdgeStacks := []EdgeStackID{} + relatedEdgeStacks := []portainer.EdgeStackID{} for _, edgeStack := range edgeStacks { for _, edgeGroupID := range edgeStack.EdgeGroups { if relatedEdgeGroupsSet[edgeGroupID] { diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go new file mode 100644 index 000000000..da9565cce --- /dev/null +++ b/api/internal/snapshot/snapshot.go @@ -0,0 +1,181 @@ +package snapshot + +import ( + "log" + "time" + + "github.com/portainer/portainer/api" +) + +// Service repesents a service to manage endpoint snapshots. +// It provides an interface to start background snapshots as well as +// specific Docker/Kubernetes endpoint snapshot methods. +type Service struct { + dataStore portainer.DataStore + refreshSignal chan struct{} + snapshotIntervalInSeconds float64 + dockerSnapshotter portainer.DockerSnapshotter + kubernetesSnapshotter portainer.KubernetesSnapshotter +} + +// NewService creates a new instance of a service +func NewService(snapshotInterval string, dataStore portainer.DataStore, dockerSnapshotter portainer.DockerSnapshotter, kubernetesSnapshotter portainer.KubernetesSnapshotter) (*Service, error) { + snapshotFrequency, err := time.ParseDuration(snapshotInterval) + if err != nil { + return nil, err + } + + return &Service{ + dataStore: dataStore, + snapshotIntervalInSeconds: snapshotFrequency.Seconds(), + dockerSnapshotter: dockerSnapshotter, + kubernetesSnapshotter: kubernetesSnapshotter, + }, nil +} + +// Start will start a background routine to execute periodic snapshots of endpoints +func (service *Service) Start() { + if service.refreshSignal != nil { + return + } + + service.refreshSignal = make(chan struct{}) + service.startSnapshotLoop() +} + +func (service *Service) stop() { + if service.refreshSignal == nil { + return + } + + close(service.refreshSignal) +} + +// SetSnapshotInterval sets the snapshot interval and resets the service +func (service *Service) SetSnapshotInterval(snapshotInterval string) error { + service.stop() + + snapshotFrequency, err := time.ParseDuration(snapshotInterval) + if err != nil { + return err + } + service.snapshotIntervalInSeconds = snapshotFrequency.Seconds() + + service.Start() + + return nil +} + +// SupportDirectSnapshot checks whether an endpoint can be used to trigger a direct a snapshot. +// It is mostly true for all endpoints except Edge and Azure endpoints. +func SupportDirectSnapshot(endpoint *portainer.Endpoint) bool { + switch endpoint.Type { + case portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment, portainer.AzureEnvironment: + return false + } + return true +} + +// SnapshotEndpoint will create a snapshot of the endpoint based on the endpoint type. +// If the snapshot is a success, it will be associated to the endpoint. +func (service *Service) SnapshotEndpoint(endpoint *portainer.Endpoint) error { + switch endpoint.Type { + case portainer.AzureEnvironment: + return nil + case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment: + return service.snapshotKubernetesEndpoint(endpoint) + } + + return service.snapshotDockerEndpoint(endpoint) +} + +func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error { + snapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint) + if err != nil { + return err + } + + if snapshot != nil { + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot} + } + + return nil +} + +func (service *Service) snapshotDockerEndpoint(endpoint *portainer.Endpoint) error { + snapshot, err := service.dockerSnapshotter.CreateSnapshot(endpoint) + if err != nil { + return err + } + + if snapshot != nil { + endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot} + } + + return nil +} + +func (service *Service) startSnapshotLoop() error { + ticker := time.NewTicker(time.Duration(service.snapshotIntervalInSeconds) * time.Second) + go func() { + err := service.snapshotEndpoints() + if err != nil { + log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err) + } + + for { + select { + case <-ticker.C: + err := service.snapshotEndpoints() + if err != nil { + log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err) + } + + case <-service.refreshSignal: + log.Println("[DEBUG] [internal,snapshot] [message: shutting down Snapshot service]") + ticker.Stop() + return + } + } + }() + + return nil +} + +func (service *Service) snapshotEndpoints() error { + endpoints, err := service.dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if !SupportDirectSnapshot(&endpoint) { + continue + } + + snapshotError := service.SnapshotEndpoint(&endpoint) + + latestEndpointReference, err := service.dataStore.Endpoint().Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + latestEndpointReference.Snapshots = endpoint.Snapshots + latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots + + err = service.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + continue + } + } + + return nil +} diff --git a/api/tag.go b/api/internal/tag/tag.go similarity index 54% rename from api/tag.go rename to api/internal/tag/tag.go index f93c6b547..8dd5ad58b 100644 --- a/api/tag.go +++ b/api/internal/tag/tag.go @@ -1,18 +1,20 @@ -package portainer +package tag -type tagSet map[TagID]bool +import "github.com/portainer/portainer/api" -// TagSet converts an array of ids to a set -func TagSet(tagIDs []TagID) tagSet { - set := map[TagID]bool{} +type tagSet map[portainer.TagID]bool + +// Set converts an array of ids to a set +func Set(tagIDs []portainer.TagID) tagSet { + set := map[portainer.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 returns a set intersection of the provided sets +func Intersection(sets ...tagSet) tagSet { intersection := tagSet{} if len(sets) == 0 { return intersection @@ -35,8 +37,8 @@ func TagIntersection(sets ...tagSet) tagSet { return intersection } -// TagUnion returns a set union of provided sets -func TagUnion(sets ...tagSet) tagSet { +// Union returns a set union of provided sets +func Union(sets ...tagSet) tagSet { union := tagSet{} for _, set := range sets { for tag := range set { @@ -46,8 +48,8 @@ func TagUnion(sets ...tagSet) tagSet { return union } -// TagContains return true if setA contains setB -func TagContains(setA tagSet, setB tagSet) bool { +// Contains return true if setA contains setB +func Contains(setA tagSet, setB tagSet) bool { containedTags := 0 for tag := range setB { if setA[tag] { @@ -57,8 +59,8 @@ func TagContains(setA tagSet, setB tagSet) bool { return containedTags == len(setA) } -// TagDifference returns the set difference tagsA - tagsB -func TagDifference(setA tagSet, setB tagSet) tagSet { +// Difference returns the set difference tagsA - tagsB +func Difference(setA tagSet, setB tagSet) tagSet { set := tagSet{} for tag := range setA { diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 316495724..4bd9f8fec 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -1,6 +1,8 @@ package jwt import ( + "errors" + "github.com/portainer/portainer/api" "fmt" @@ -12,7 +14,8 @@ import ( // Service represents a service for managing JWT tokens. type Service struct { - secret []byte + secret []byte + userSessionTimeout time.Duration } type claims struct { @@ -22,21 +25,33 @@ type claims struct { jwt.StandardClaims } +var ( + errSecretGeneration = errors.New("Unable to generate secret key") + errInvalidJWTToken = errors.New("Invalid JWT token") +) + // NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. -func NewService() (*Service, error) { +func NewService(userSessionDuration string) (*Service, error) { + userSessionTimeout, err := time.ParseDuration(userSessionDuration) + if err != nil { + return nil, err + } + secret := securecookie.GenerateRandomKey(32) if secret == nil { - return nil, portainer.ErrSecretGeneration + return nil, errSecretGeneration } + service := &Service{ secret, + userSessionTimeout, } return service, nil } // GenerateToken generates a new JWT token. func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - expireToken := time.Now().Add(time.Hour * 8).Unix() + expireToken := time.Now().Add(service.userSessionTimeout).Unix() cl := claims{ UserID: int(data.ID), Username: data.Username, @@ -75,5 +90,10 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, } } - return nil, portainer.ErrInvalidJWTToken + return nil, errInvalidJWTToken +} + +// SetUserSessionDuration sets the user session duration +func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) { + service.userSessionTimeout = userSessionDuration } diff --git a/api/kubernetes.go b/api/kubernetes.go new file mode 100644 index 000000000..45acea995 --- /dev/null +++ b/api/kubernetes.go @@ -0,0 +1,13 @@ +package portainer + +func KubernetesDefault() KubernetesData { + return KubernetesData{ + Configuration: KubernetesConfiguration{ + UseLoadBalancer: false, + UseServerMetrics: false, + StorageClasses: []KubernetesStorageClassConfig{}, + IngressClasses: []KubernetesIngressClassConfig{}, + }, + Snapshots: []KubernetesSnapshot{}, + } +} diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go new file mode 100644 index 000000000..bd79d3fce --- /dev/null +++ b/api/kubernetes/cli/access.go @@ -0,0 +1,86 @@ +package cli + +import ( + "encoding/json" + + portainer "github.com/portainer/portainer/api" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ( + accessPolicies struct { + UserAccessPolicies portainer.UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies portainer.TeamAccessPolicies `json:"TeamAccessPolicies"` + } + + namespaceAccessPolicies map[string]accessPolicies +) + +func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error { + configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil + } else if err != nil { + return err + } + + accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] + + var accessPolicies namespaceAccessPolicies + err = json.Unmarshal([]byte(accessData), &accessPolicies) + if err != nil { + return err + } + + namespaces, err := kcl.cli.CoreV1().Namespaces().List(metav1.ListOptions{}) + if err != nil { + return err + } + + for _, namespace := range namespaces.Items { + if namespace.Name == defaultNamespace { + continue + } + + policies, ok := accessPolicies[namespace.Name] + if !ok { + err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name) + if err != nil { + return err + } + continue + } + + if !hasUserAccessToNamespace(userID, teamIDs, policies) { + err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name) + if err != nil { + return err + } + continue + } + + err = kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + } + + return nil +} + +func hasUserAccessToNamespace(userID int, teamIDs []int, policies accessPolicies) bool { + _, userAccess := policies.UserAccessPolicies[portainer.UserID(userID)] + if userAccess { + return true + } + + for _, teamID := range teamIDs { + _, teamAccess := policies.TeamAccessPolicies[portainer.TeamID(teamID)] + if teamAccess { + return true + } + } + + return false +} diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go new file mode 100644 index 000000000..707bf3924 --- /dev/null +++ b/api/kubernetes/cli/client.go @@ -0,0 +1,149 @@ +package cli + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + cmap "github.com/orcaman/concurrent-map" + + portainer "github.com/portainer/portainer/api" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type ( + // ClientFactory is used to create Kubernetes clients + ClientFactory struct { + reverseTunnelService portainer.ReverseTunnelService + signatureService portainer.DigitalSignatureService + instanceID string + endpointClients cmap.ConcurrentMap + } + + // KubeClient represent a service used to execute Kubernetes operations + KubeClient struct { + cli *kubernetes.Clientset + instanceID string + } +) + +// NewClientFactory returns a new instance of a ClientFactory +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *ClientFactory { + return &ClientFactory{ + signatureService: signatureService, + reverseTunnelService: reverseTunnelService, + instanceID: instanceID, + endpointClients: cmap.New(), + } +} + +// GetKubeClient checks if an existing client is already registered for the endpoint and returns it if one is found. +// If no client is registered, it will create a new client, register it, and returns it. +func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { + key := strconv.Itoa(int(endpoint.ID)) + client, ok := factory.endpointClients.Get(key) + if !ok { + client, err := factory.createKubeClient(endpoint) + if err != nil { + return nil, err + } + + factory.endpointClients.Set(key, client) + return client, nil + } + + return client.(portainer.KubeClient), nil +} + +func (factory *ClientFactory) createKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { + cli, err := factory.CreateClient(endpoint) + if err != nil { + return nil, err + } + + kubecli := &KubeClient{ + cli: cli, + instanceID: factory.instanceID, + } + + return kubecli, nil +} + +// CreateClient returns a pointer to a new Clientset instance +func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { + switch endpoint.Type { + case portainer.KubernetesLocalEnvironment: + return buildLocalClient() + case portainer.AgentOnKubernetesEnvironment: + return factory.buildAgentClient(endpoint) + case portainer.EdgeAgentOnKubernetesEnvironment: + return factory.buildEdgeClient(endpoint) + } + + return nil, errors.New("unsupported endpoint type") +} + +type agentHeaderRoundTripper struct { + signatureHeader string + publicKeyHeader string + + roundTripper http.RoundTripper +} + +// RoundTrip is the implementation of the http.RoundTripper interface. +// It decorates the request with specific agent headers +func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add(portainer.PortainerAgentPublicKeyHeader, rt.publicKeyHeader) + req.Header.Add(portainer.PortainerAgentSignatureHeader, rt.signatureHeader) + + return rt.roundTripper.RoundTrip(req) +} + +func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { + endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL) + signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") + if err != nil { + return nil, err + } + config.Insecure = true + + config.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return &agentHeaderRoundTripper{ + signatureHeader: signature, + publicKeyHeader: factory.signatureService.EncodedPublicKey(), + roundTripper: rt, + } + }) + + return kubernetes.NewForConfig(config) +} + +func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { + tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + endpointURL := fmt.Sprintf("http://localhost:%d/kubernetes", tunnel.Port) + + config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") + if err != nil { + return nil, err + } + config.Insecure = true + + return kubernetes.NewForConfig(config) +} + +func buildLocalClient() (*kubernetes.Clientset, error) { + config, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(config) +} diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go new file mode 100644 index 000000000..1716b10e6 --- /dev/null +++ b/api/kubernetes/cli/exec.go @@ -0,0 +1,57 @@ +package cli + +import ( + "errors" + "io" + + "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + utilexec "k8s.io/client-go/util/exec" +) + +// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace +// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write +// to the stdout parameter. +// This function only works against a local endpoint using an in-cluster config. +func (kcl *KubeClient) StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error { + config, err := rest.InClusterConfig() + if err != nil { + return err + } + + req := kcl.cli.CoreV1().RESTClient(). + Post(). + Resource("pods"). + Name(podName). + Namespace(namespace). + SubResource("exec") + + req.VersionedParams(&v1.PodExecOptions{ + Container: containerName, + Command: command, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: true, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) + if err != nil { + return err + } + + err = exec.Stream(remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: stdout, + Tty: true, + }) + if err != nil { + if _, ok := err.(utilexec.ExitError); !ok { + return errors.New("unable to start exec process") + } + } + + return nil +} diff --git a/api/kubernetes/cli/naming.go b/api/kubernetes/cli/naming.go new file mode 100644 index 000000000..9297ff6be --- /dev/null +++ b/api/kubernetes/cli/naming.go @@ -0,0 +1,26 @@ +package cli + +import "fmt" + +const ( + defaultNamespace = "default" + portainerNamespace = "portainer" + portainerUserCRName = "portainer-cr-user" + portainerUserCRBName = "portainer-crb-user" + portainerUserServiceAccountPrefix = "portainer-sa-user" + portainerRBPrefix = "portainer-rb" + portainerConfigMapName = "portainer-config" + portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" +) + +func userServiceAccountName(userID int, instanceID string) string { + return fmt.Sprintf("%s-%s-%d", portainerUserServiceAccountPrefix, instanceID, userID) +} + +func userServiceAccountTokenSecretName(serviceAccountName string, instanceID string) string { + return fmt.Sprintf("%s-%s-secret", instanceID, serviceAccountName) +} + +func namespaceClusterRoleBindingName(namespace string, instanceID string) string { + return fmt.Sprintf("%s-%s-%s", portainerRBPrefix, instanceID, namespace) +} diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go new file mode 100644 index 000000000..d75afa3c1 --- /dev/null +++ b/api/kubernetes/cli/role.go @@ -0,0 +1,43 @@ +package cli + +import ( + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + { + Verbs: []string{"list"}, + Resources: []string{"namespaces", "nodes"}, + APIGroups: []string{""}, + }, + { + Verbs: []string{"list"}, + Resources: []string{"storageclasses"}, + APIGroups: []string{"storage.k8s.io"}, + }, + { + Verbs: []string{"list"}, + Resources: []string{"ingresses"}, + APIGroups: []string{"networking.k8s.io"}, + }, + } +} + +func (kcl *KubeClient) createPortainerUserClusterRole() error { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerUserCRName, + }, + Rules: getPortainerUserDefaultPolicies(), + } + + _, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + + return nil +} diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go new file mode 100644 index 000000000..3235cb304 --- /dev/null +++ b/api/kubernetes/cli/secret.go @@ -0,0 +1,74 @@ +package cli + +import ( + "errors" + "time" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) createServiceAccountToken(serviceAccountName string) error { + serviceAccountSecretName := userServiceAccountTokenSecretName(serviceAccountName, kcl.instanceID) + + serviceAccountSecret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountSecretName, + Annotations: map[string]string{ + "kubernetes.io/service-account.name": serviceAccountName, + }, + }, + Type: "kubernetes.io/service-account-token", + } + + _, err := kcl.cli.CoreV1().Secrets(portainerNamespace).Create(serviceAccountSecret) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + + return nil +} + +func (kcl *KubeClient) getServiceAccountToken(serviceAccountName string) (string, error) { + serviceAccountSecretName := userServiceAccountTokenSecretName(serviceAccountName, kcl.instanceID) + + secret, err := kcl.cli.CoreV1().Secrets(portainerNamespace).Get(serviceAccountSecretName, metav1.GetOptions{}) + if err != nil { + return "", err + } + + // API token secret is populated asynchronously. + // Is it created by the controller and will depend on the environment/secret-store: + // https://github.com/kubernetes/kubernetes/issues/67882#issuecomment-422026204 + // as a work-around, we wait for up to 5 seconds for the secret to be populated. + timeout := time.After(5 * time.Second) + searchingForSecret := true + for searchingForSecret { + select { + case <-timeout: + return "", errors.New("unable to find secret token associated to user service account (timeout)") + default: + secret, err = kcl.cli.CoreV1().Secrets(portainerNamespace).Get(serviceAccountSecretName, metav1.GetOptions{}) + if err != nil { + return "", err + } + + if len(secret.Data) > 0 { + searchingForSecret = false + break + } + + time.Sleep(1 * time.Second) + } + } + + secretTokenData, ok := secret.Data["token"] + if ok { + return string(secretTokenData), nil + } + + return "", errors.New("unable to find secret token associated to user service account") +} diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go new file mode 100644 index 000000000..d8abc6f0f --- /dev/null +++ b/api/kubernetes/cli/service_account.go @@ -0,0 +1,182 @@ +package cli + +import ( + "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user. +func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) { + serviceAccountName := userServiceAccountName(userID, kcl.instanceID) + + return kcl.getServiceAccountToken(serviceAccountName) +} + +// SetupUserServiceAccount will make sure that all the required resources are created inside the Kubernetes +// cluster before creating a ServiceAccount and a ServiceAccountToken for the specified Portainer user. +//It will also create required default RoleBinding and ClusterRoleBinding rules. +func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error { + serviceAccountName := userServiceAccountName(userID, kcl.instanceID) + + err := kcl.ensureRequiredResourcesExist() + if err != nil { + return err + } + + err = kcl.ensureServiceAccountForUserExists(serviceAccountName) + if err != nil { + return err + } + + return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName) +} + +func (kcl *KubeClient) ensureRequiredResourcesExist() error { + return kcl.createPortainerUserClusterRole() +} + +func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName string) error { + err := kcl.createUserServiceAccount(portainerNamespace, serviceAccountName) + if err != nil { + return err + } + + err = kcl.createServiceAccountToken(serviceAccountName) + if err != nil { + return err + } + + err = kcl.ensureServiceAccountHasPortainerUserClusterRole(serviceAccountName) + if err != nil { + return err + } + + return kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace) +} + +func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error { + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + }, + } + + _, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Create(serviceAccount) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + + return nil +} + +func (kcl *KubeClient) ensureServiceAccountHasPortainerUserClusterRole(serviceAccountName string) error { + clusterRoleBinding, err := kcl.cli.RbacV1().ClusterRoleBindings().Get(portainerUserCRBName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + clusterRoleBinding = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerUserCRBName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: portainerNamespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: portainerUserCRName, + }, + } + + _, err := kcl.cli.RbacV1().ClusterRoleBindings().Create(clusterRoleBinding) + return err + } else if err != nil { + return err + } + + for _, subject := range clusterRoleBinding.Subjects { + if subject.Name == serviceAccountName { + return nil + } + } + + clusterRoleBinding.Subjects = append(clusterRoleBinding.Subjects, rbacv1.Subject{ + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: portainerNamespace, + }) + + _, err = kcl.cli.RbacV1().ClusterRoleBindings().Update(clusterRoleBinding) + return err +} + +func (kcl *KubeClient) removeNamespaceAccessForServiceAccount(serviceAccountName, namespace string) error { + roleBindingName := namespaceClusterRoleBindingName(namespace, kcl.instanceID) + + roleBinding, err := kcl.cli.RbacV1().RoleBindings(namespace).Get(roleBindingName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return nil + } else if err != nil { + return err + } + + updatedSubjects := roleBinding.Subjects[:0] + + for _, subject := range roleBinding.Subjects { + if subject.Name != serviceAccountName { + updatedSubjects = append(updatedSubjects, subject) + } + } + + roleBinding.Subjects = updatedSubjects + + _, err = kcl.cli.RbacV1().RoleBindings(namespace).Update(roleBinding) + return err +} + +func (kcl *KubeClient) ensureNamespaceAccessForServiceAccount(serviceAccountName, namespace string) error { + roleBindingName := namespaceClusterRoleBindingName(namespace, kcl.instanceID) + + roleBinding, err := kcl.cli.RbacV1().RoleBindings(namespace).Get(roleBindingName, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + roleBinding = &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleBindingName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: portainerNamespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: "edit", + }, + } + + _, err = kcl.cli.RbacV1().RoleBindings(namespace).Create(roleBinding) + return err + } else if err != nil { + return err + } + + for _, subject := range roleBinding.Subjects { + if subject.Name == serviceAccountName { + return nil + } + } + + roleBinding.Subjects = append(roleBinding.Subjects, rbacv1.Subject{ + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: portainerNamespace, + }) + + _, err = kcl.cli.RbacV1().RoleBindings(namespace).Update(roleBinding) + return err +} diff --git a/api/kubernetes/snapshot.go b/api/kubernetes/snapshot.go new file mode 100644 index 000000000..8382d95ab --- /dev/null +++ b/api/kubernetes/snapshot.go @@ -0,0 +1,83 @@ +package kubernetes + +import ( + "log" + "time" + + "github.com/portainer/portainer/api/kubernetes/cli" + + portainer "github.com/portainer/portainer/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type Snapshotter struct { + clientFactory *cli.ClientFactory +} + +// NewSnapshotter returns a new Snapshotter instance +func NewSnapshotter(clientFactory *cli.ClientFactory) *Snapshotter { + return &Snapshotter{ + clientFactory: clientFactory, + } +} + +// CreateSnapshot creates a snapshot of a specific Kubernetes endpoint +func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.KubernetesSnapshot, error) { + client, err := snapshotter.clientFactory.CreateClient(endpoint) + if err != nil { + return nil, err + } + + return snapshot(client, endpoint) +} + +func snapshot(cli *kubernetes.Clientset, endpoint *portainer.Endpoint) (*portainer.KubernetesSnapshot, error) { + res := cli.RESTClient().Get().AbsPath("/healthz").Do() + if res.Error() != nil { + return nil, res.Error() + } + + snapshot := &portainer.KubernetesSnapshot{} + + err := snapshotVersion(snapshot, cli) + if err != nil { + log.Printf("[WARN] [kubernetes,snapshot] [message: unable to snapshot cluster version] [endpoint: %s] [err: %s]", endpoint.Name, err) + } + + err = snapshotNodes(snapshot, cli) + if err != nil { + log.Printf("[WARN] [kubernetes,snapshot] [message: unable to snapshot cluster nodes] [endpoint: %s] [err: %s]", endpoint.Name, err) + } + + snapshot.Time = time.Now().Unix() + return snapshot, nil +} + +func snapshotVersion(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error { + versionInfo, err := cli.ServerVersion() + if err != nil { + return err + } + + snapshot.KubernetesVersion = versionInfo.GitVersion + return nil +} + +func snapshotNodes(snapshot *portainer.KubernetesSnapshot, cli *kubernetes.Clientset) error { + nodeList, err := cli.CoreV1().Nodes().List(metav1.ListOptions{}) + if err != nil { + return err + } + + var totalCPUs, totalMemory int64 + for _, node := range nodeList.Items { + totalCPUs += node.Status.Capacity.Cpu().Value() + totalMemory += node.Status.Capacity.Memory().Value() + } + + snapshot.TotalCPU = totalCPUs + snapshot.TotalMemory = totalMemory + snapshot.NodeCount = len(nodeList.Items) + return nil +} diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index e4ccbe848..89c08ec61 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -1,19 +1,20 @@ package ldap import ( + "errors" "fmt" "strings" + ldap "github.com/go-ldap/ldap/v3" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" - - "gopkg.in/ldap.v2" + httperrors "github.com/portainer/portainer/api/http/errors" ) -const ( - // ErrUserNotFound defines an error raised when the user is not found via LDAP search +var ( + // errUserNotFound defines an error raised when the user is not found via LDAP search // or that too many entries (> 1) are returned. - ErrUserNotFound = portainer.Error("User not found or too many entries returned") + errUserNotFound = errors.New("User not found or too many entries returned") ) // Service represents a service used to authenticate users against a LDAP/AD. @@ -48,7 +49,7 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc } if !found { - return "", ErrUserNotFound + return "", errUserNotFound } return userDN, nil @@ -106,7 +107,7 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer. err = connection.Bind(userDN, password) if err != nil { - return portainer.ErrUnauthorized + return httperrors.ErrUnauthorized } return nil diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index d3bf546c3..ec885b65b 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -37,7 +37,7 @@ func NewComposeStackManager(dataPath string, reverseTunnelService portainer.Reve func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (client.Factory, error) { endpointURL := endpoint.URL - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port) } diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go new file mode 100644 index 000000000..f0bf6b102 --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,137 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "io/ioutil" + "log" + "mime" + "net/http" + "net/url" + + "github.com/portainer/portainer/api" +) + +// Service represents a service used to authenticate users against an authorization server +type Service struct{} + +// NewService returns a pointer to a new instance of this service +func NewService() *Service { + return &Service{} +} + +// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint. +// On success, it will then return the username associated to authenticated user by fetching this information +// from the resource server and matching it with the user identifier setting. +func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) { + token, err := getAccessToken(code, configuration) + if err != nil { + log.Printf("[DEBUG] - Failed retrieving access token: %v", err) + return "", err + } + + return getUsername(token, configuration) +} + +func getAccessToken(code string, configuration *portainer.OAuthSettings) (string, error) { + unescapedCode, err := url.QueryUnescape(code) + if err != nil { + return "", err + } + + config := buildConfig(configuration) + token, err := config.Exchange(context.Background(), unescapedCode) + if err != nil { + return "", err + } + + return token.AccessToken, nil +} + +func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) { + req, err := http.NewRequest("GET", configuration.ResourceURI, nil) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + + content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return "", err + } + + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + + username := values.Get(configuration.UserIdentifier) + if username == "" { + return username, &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + + return username, nil + } + + var datamap map[string]interface{} + if err = json.Unmarshal(body, &datamap); err != nil { + return "", err + } + + username, ok := datamap[configuration.UserIdentifier].(string) + if ok && username != "" { + return username, nil + } + + if !ok { + username, ok := datamap[configuration.UserIdentifier].(float64) + if ok && username != 0 { + return fmt.Sprint(int(username)), nil + } + } + + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } +} + +func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config { + endpoint := oauth2.Endpoint{ + AuthURL: configuration.AuthorizationURI, + TokenURL: configuration.AccessTokenURI, + } + + return &oauth2.Config{ + ClientID: configuration.ClientID, + ClientSecret: configuration.ClientSecret, + Endpoint: endpoint, + RedirectURL: configuration.RedirectURI, + Scopes: []string{configuration.Scopes}, + } +} diff --git a/api/portainer.go b/api/portainer.go index 9b6149555..e68e0c223 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,6 +1,9 @@ package portainer -import "time" +import ( + "io" + "time" +) type ( // AccessPolicy represent a policy that can be associated to a user or team @@ -8,12 +11,8 @@ type ( RoleID RoleID `json:"RoleId"` } - // APIOperationAuthorizationRequest represent an request for the authorization to execute an API operation - APIOperationAuthorizationRequest struct { - Path string - Method string - Authorizations Authorizations - } + // AgentPlatform represents a platform type for an Agent + AgentPlatform int // AuthenticationMethod represents the authentication method used to authenticate a user AuthenticationMethod int @@ -34,47 +33,50 @@ type ( // CLIFlags represents the available flags on the CLI CLIFlags struct { - Addr *string - TunnelAddr *string - TunnelPort *string - AdminPassword *string - AdminPasswordFile *string - Assets *string - Data *string - EndpointURL *string - ExternalEndpoints *string - Labels *[]Pair - Logo *string - NoAuth *bool - NoAnalytics *bool - Templates *string - TemplateFile *string - TLS *bool - TLSSkipVerify *bool - TLSCacert *string - TLSCert *string - TLSKey *string - SSL *bool - SSLCert *string - SSLKey *string - SyncInterval *string - Snapshot *bool - SnapshotInterval *string + Addr *string + TunnelAddr *string + TunnelPort *string + AdminPassword *string + AdminPasswordFile *string + Assets *string + Data *string + EnableEdgeComputeFeatures *bool + EndpointURL *string + Labels *[]Pair + Logo *string + NoAnalytics *bool + Templates *string + TLS *bool + TLSSkipVerify *bool + TLSCacert *string + TLSCert *string + TLSKey *string + SSL *bool + SSLCert *string + SSLKey *string + SnapshotInterval *string } - // CLIService represents a service for managing CLI - CLIService interface { - ParseFlags(version string) (*CLIFlags, error) - ValidateFlags(flags *CLIFlags) error + // CustomTemplate represents a custom template + CustomTemplate struct { + ID CustomTemplateID `json:"Id"` + Title string `json:"Title"` + Description string `json:"Description"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + CreatedByUserID UserID `json:"CreatedByUserId"` + Note string `json:"Note"` + Platform CustomTemplatePlatform `json:"Platform"` + Logo string `json:"Logo"` + Type StackType `json:"Type"` + ResourceControl *ResourceControl `json:"ResourceControl"` } - // DataStore defines the interface to manage the data - DataStore interface { - Open() error - Init() error - Close() error - MigrateData() error - } + // CustomTemplateID represents a custom template identifier + CustomTemplateID int + + // CustomTemplatePlatform represents a custom template platform + CustomTemplatePlatform int // DockerHub represents all the required information to connect and use the // Docker Hub @@ -84,6 +86,34 @@ type ( Password string `json:"Password,omitempty"` } + // DockerSnapshot represents a snapshot of a specific Docker endpoint at a specific time + DockerSnapshot struct { + Time int64 `json:"Time"` + DockerVersion string `json:"DockerVersion"` + Swarm bool `json:"Swarm"` + TotalCPU int `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + RunningContainerCount int `json:"RunningContainerCount"` + StoppedContainerCount int `json:"StoppedContainerCount"` + HealthyContainerCount int `json:"HealthyContainerCount"` + UnhealthyContainerCount int `json:"UnhealthyContainerCount"` + VolumeCount int `json:"VolumeCount"` + ImageCount int `json:"ImageCount"` + ServiceCount int `json:"ServiceCount"` + StackCount int `json:"StackCount"` + SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` + } + + // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API + DockerSnapshotRaw struct { + Containers interface{} `json:"Containers"` + Volumes interface{} `json:"Volumes"` + Networks interface{} `json:"Networks"` + Images interface{} `json:"Images"` + Info interface{} `json:"Info"` + Version interface{} `json:"Version"` + } + // EdgeGroup represents an Edge group EdgeGroup struct { ID EdgeGroupID `json:"Id"` @@ -97,7 +127,32 @@ type ( // EdgeGroupID represents an Edge group identifier EdgeGroupID int + // EdgeJob represents a job that can run on Edge environments. + EdgeJob struct { + ID EdgeJobID `json:"Id"` + Created int64 `json:"Created"` + CronExpression string `json:"CronExpression"` + Endpoints map[EndpointID]EdgeJobEndpointMeta `json:"Endpoints"` + Name string `json:"Name"` + ScriptPath string `json:"ScriptPath"` + Recurring bool `json:"Recurring"` + Version int `json:"Version"` + } + + // EdgeJobEndpointMeta represents a meta data object for an Edge job and Endpoint relation + EdgeJobEndpointMeta struct { + LogsStatus EdgeJobLogsStatus + CollectLogs bool + } + + // EdgeJobID represents an Edge job identifier + EdgeJobID int + + // EdgeJobLogsStatus represent status of logs collection job + EdgeJobLogsStatus int + // EdgeSchedule represents a scheduled job that can run on Edge environments. + // Deprecated in favor of EdgeJob EdgeSchedule struct { ID ScheduleID `json:"Id"` CronExpression string `json:"CronExpression"` @@ -135,22 +190,25 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - Type EndpointType `json:"Type"` - URL string `json:"URL"` - GroupID EndpointGroupID `json:"GroupId"` - PublicURL string `json:"PublicURL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - 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"` + 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 []DockerSnapshot `json:"Snapshots"` + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + EdgeID string `json:"EdgeID,omitempty"` + EdgeKey string `json:"EdgeKey"` + EdgeCheckinInterval int `json:"EdgeCheckinInterval"` + Kubernetes KubernetesData `json:"Kubernetes"` + // Deprecated fields // Deprecated in DBVersion == 4 TLS bool `json:"TLS,omitempty"` @@ -210,6 +268,7 @@ type ( EndpointStatus int // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file + // Deprecated EndpointSyncJob struct{} // EndpointType represents the type of an endpoint @@ -221,7 +280,7 @@ type ( EdgeStacks map[EdgeStackID]bool } - // Extension represents a Portainer extension + // Extension represents a deprecated Portainer extension Extension struct { ID ExtensionID `json:"Id"` Enabled bool `json:"Enabled"` @@ -254,6 +313,43 @@ type ( // JobType represents a job type JobType int + // KubernetesData contains all the Kubernetes related endpoint information + KubernetesData struct { + Snapshots []KubernetesSnapshot `json:"Snapshots"` + Configuration KubernetesConfiguration `json:"Configuration"` + } + + // KubernetesSnapshot represents a snapshot of a specific Kubernetes endpoint at a specific time + KubernetesSnapshot struct { + Time int64 `json:"Time"` + KubernetesVersion string `json:"KubernetesVersion"` + NodeCount int `json:"NodeCount"` + TotalCPU int64 `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + } + + // KubernetesConfiguration represents the configuration of a Kubernetes endpoint + KubernetesConfiguration struct { + UseLoadBalancer bool `json:"UseLoadBalancer"` + UseServerMetrics bool `json:"UseServerMetrics"` + StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` + IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` + } + + // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration + KubernetesStorageClassConfig struct { + Name string `json:"Name"` + AccessModes []string `json:"AccessModes"` + Provisioner string `json:"Provisioner"` + AllowVolumeExpansion bool `json:"AllowVolumeExpansion"` + } + + // KubernetesIngressClassConfig represents a Kubernetes Ingress Class configuration + KubernetesIngressClassConfig struct { + Name string `json:"Name"` + Type string `json:"Type"` + } + // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server LDAPGroupSearchSettings struct { GroupBaseDN string `json:"GroupBaseDN"` @@ -393,20 +489,19 @@ type ( // It only contains a pointer to one of the JobRunner implementations // based on the JobType. // NOTE: The Recurring option is only used by ScriptExecutionJob at the moment + // Deprecated in favor of EdgeJob Schedule struct { - ID ScheduleID `json:"Id"` - Name string - CronExpression string - Recurring bool - Created int64 - JobType JobType - EdgeSchedule *EdgeSchedule - ScriptExecutionJob *ScriptExecutionJob - SnapshotJob *SnapshotJob - EndpointSyncJob *EndpointSyncJob + ID ScheduleID `json:"Id"` + Name string + CronExpression string + Recurring bool + Created int64 + JobType JobType + EdgeSchedule *EdgeSchedule } // ScheduleID represents a schedule identifier. + // Deprecated in favor of EdgeJob ScheduleID int // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container @@ -420,56 +515,34 @@ type ( // 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"` + 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"` + AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"` + AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"` + AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"` + AllowContainerCapabilitiesForRegularUsers bool `json:"AllowContainerCapabilitiesForRegularUsers"` + SnapshotInterval string `json:"SnapshotInterval"` + TemplatesURL string `json:"TemplatesURL"` + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` + EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` + UserSessionTimeout string `json:"UserSessionTimeout"` + EnableTelemetry bool `json:"EnableTelemetry"` // Deprecated fields DisplayDonationHeader bool DisplayExternalContributors bool } - // Snapshot represents a snapshot of a specific endpoint at a specific time - Snapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - HealthyContainerCount int `json:"HealthyContainerCount"` - UnhealthyContainerCount int `json:"UnhealthyContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` - 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"` - Volumes interface{} `json:"Volumes"` - Networks interface{} `json:"Networks"` - Images interface{} `json:"Images"` - Info interface{} `json:"Info"` - Version interface{} `json:"Version"` - } - // Stack represents a Docker stack created via docker stack deploy Stack struct { ID StackID `json:"Id"` @@ -480,22 +553,22 @@ type ( EntryPoint string `json:"EntryPoint"` Env []Pair `json:"Env"` ResourceControl *ResourceControl `json:"ResourceControl"` + Status StackStatus `json:"Status"` ProjectPath string } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) StackID int + // StackStatus represent a status for a stack + StackStatus 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"` + Version string `json:"Version"` } // Tag represents a tag that can be associated to a resource @@ -538,7 +611,8 @@ type ( AccessLevel ResourceAccessLevel `json:"AccessLevel"` } - // Template represents an application template + // Template represents an application template that can be used as an App Template + // or an Edge template Template struct { // Mandatory container/stack fields ID TemplateID `json:"Id"` @@ -553,7 +627,7 @@ type ( // Mandatory stack fields Repository TemplateRepository `json:"repository"` - // Mandatory edge stack fields + // Mandatory Edge stack fields StackFile string `json:"stackFile"` // Optional stack/container fields @@ -639,7 +713,7 @@ type ( Status string LastActivity time.Time Port int - Schedules []EdgeSchedule + Jobs []EdgeJob Credentials string } @@ -650,10 +724,13 @@ type ( // User represents a user account User struct { - ID UserID `json:"Id"` - Username string `json:"Username"` - Password string `json:"Password,omitempty"` - Role UserRole `json:"Role"` + ID UserID `json:"Id"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + Role UserRole `json:"Role"` + + // Deprecated fields + // Deprecated in DBVersion == 25 PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"` EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"` } @@ -689,6 +766,12 @@ type ( // WebhookType represents the type of resource a webhook is related to WebhookType int + // CLIService represents a service for managing CLI + CLIService interface { + ParseFlags(version string) (*CLIFlags, error) + ValidateFlags(flags *CLIFlags) error + } + // ComposeStackManager represents a service to manage Compose stacks ComposeStackManager interface { Up(stack *Stack, endpoint *Endpoint) error @@ -701,6 +784,46 @@ type ( CompareHashAndData(hash string, data string) error } + // CustomTemplateService represents a service to manage custom templates + CustomTemplateService interface { + GetNextIdentifier() int + CustomTemplates() ([]CustomTemplate, error) + CustomTemplate(ID CustomTemplateID) (*CustomTemplate, error) + CreateCustomTemplate(customTemplate *CustomTemplate) error + UpdateCustomTemplate(ID CustomTemplateID, customTemplate *CustomTemplate) error + DeleteCustomTemplate(ID CustomTemplateID) error + } + + // DataStore defines the interface to manage the data + DataStore interface { + Open() error + Init() error + Close() error + IsNew() bool + MigrateData() error + + DockerHub() DockerHubService + CustomTemplate() CustomTemplateService + EdgeGroup() EdgeGroupService + EdgeJob() EdgeJobService + EdgeStack() EdgeStackService + Endpoint() EndpointService + EndpointGroup() EndpointGroupService + EndpointRelation() EndpointRelationService + Registry() RegistryService + ResourceControl() ResourceControlService + Role() RoleService + Settings() SettingsService + Stack() StackService + Tag() TagService + TeamMembership() TeamMembershipService + Team() TeamService + TunnelServer() TunnelServerService + User() UserService + Version() VersionService + Webhook() WebhookService + } + // DigitalSignatureService represents a service to manage digital signatures DigitalSignatureService interface { ParseKeyPair(private, public []byte) error @@ -716,6 +839,40 @@ type ( UpdateDockerHub(registry *DockerHub) error } + // DockerSnapshotter represents a service used to create Docker endpoint snapshots + DockerSnapshotter interface { + CreateSnapshot(endpoint *Endpoint) (*DockerSnapshot, 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 + } + + // EdgeJobService represents a service to manage Edge jobs + EdgeJobService interface { + EdgeJobs() ([]EdgeJob, error) + EdgeJob(ID EdgeJobID) (*EdgeJob, error) + CreateEdgeJob(edgeJob *EdgeJob) error + UpdateEdgeJob(ID EdgeJobID, edgeJob *EdgeJob) error + DeleteEdgeJob(ID EdgeJobID) error + GetNextIdentifier() int + } + + // 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 + } + // EndpointService represents a service for managing endpoint data EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) @@ -744,24 +901,6 @@ type ( 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) @@ -781,10 +920,15 @@ type ( 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 + StoreEdgeJobFileFromBytes(identifier string, data []byte) (string, error) + GetEdgeJobFolder(identifier string) string + ClearEdgeJobTaskLogs(edgeJobID, taskID string) error + GetEdgeJobTaskLogFileContent(edgeJobID, taskID string) (string, error) + StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID string, data []byte) error GetBinaryFolder() string + StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) + GetCustomTemplateProjectPath(identifier string) string + GetTemporaryPath() (string, error) } // GitService represents a service for managing Git @@ -793,30 +937,28 @@ type ( 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) + SetUserSessionDuration(userSessionDuration time.Duration) + } + + // KubeClient represents a service used to query a Kubernetes environment + KubeClient interface { + SetupUserServiceAccount(userID int, teamIDs []int) error + GetServiceAccountBearerToken(userID int) (string, error) + StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error + } + + // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint + KubernetesDeployer interface { + Deploy(endpoint *Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) + } + + // KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots + KubernetesSnapshotter interface { + CreateSnapshot(endpoint *Endpoint) (*KubernetesSnapshot, error) } // LDAPService represents a service used to authenticate users against a LDAP/AD @@ -826,6 +968,11 @@ type ( GetUserGroups(username string, settings *LDAPSettings) ([]string, error) } + // OAuthService represents a service used to authenticate users using OAuth + OAuthService interface { + Authenticate(code string, configuration *OAuthSettings) (string, error) + } + // RegistryService represents a service for managing registry data RegistryService interface { Registry(ID RegistryID) (*Registry, error) @@ -847,14 +994,14 @@ type ( // ReverseTunnelService represensts a service used to manage reverse tunnel connections. ReverseTunnelService interface { - StartTunnelServer(addr, port string, snapshotter Snapshotter) error + StartTunnelServer(addr, port string, snapshotService SnapshotService) 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) + AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob) + RemoveEdgeJob(edgeJobID EdgeJobID) } // RoleService represents a service for managing user roles @@ -865,17 +1012,6 @@ type ( 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) @@ -887,11 +1023,6 @@ type ( Start() error } - // Snapshotter represents a service used to create endpoint snapshots - Snapshotter interface { - CreateSnapshot(endpoint *Endpoint) (*Snapshot, error) - } - // StackService represents a service for managing stack data StackService interface { Stack(ID StackID) (*Stack, error) @@ -903,6 +1034,13 @@ type ( GetNextIdentifier() int } + // StackService represents a service for managing endpoint snapshots + SnapshotService interface { + Start() + SetSnapshotInterval(snapshotInterval string) error + SnapshotEndpoint(endpoint *Endpoint) error + } + // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) @@ -943,15 +1081,6 @@ type ( DeleteTeamMembershipByTeamID(teamID TeamID) 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 TunnelServerService interface { Info() (*TunnelServerInfo, error) @@ -973,6 +1102,8 @@ type ( VersionService interface { DBVersion() (int, error) StoreDBVersion(version int) error + InstanceID() (string, error) + StoreInstanceID(ID string) error } // WebhookService represents a service for managing webhook data. @@ -984,63 +1115,42 @@ type ( WebhookByToken(token string) (*Webhook, error) DeleteWebhook(serviceID WebhookID) error } - - // EdgeGroupService represents a service to manage Edge groups - EdgeGroupService interface { - EdgeGroups() ([]EdgeGroup, error) - EdgeGroup(ID EdgeGroupID) (*EdgeGroup, error) - CreateEdgeGroup(group *EdgeGroup) error - UpdateEdgeGroup(ID EdgeGroupID, group *EdgeGroup) error - DeleteEdgeGroup(ID EdgeGroupID) error - } - - // EdgeStackService represents a service to manage Edge stacks - EdgeStackService interface { - EdgeStacks() ([]EdgeStack, error) - EdgeStack(ID EdgeStackID) (*EdgeStack, error) - CreateEdgeStack(edgeStack *EdgeStack) error - UpdateEdgeStack(ID EdgeStackID, edgeStack *EdgeStack) error - DeleteEdgeStack(ID EdgeStackID) error - GetNextIdentifier() int - } ) const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.24.0" + APIVersion = "2.0.0" // DBVersion is the version number of the Portainer database - DBVersion = 23 + DBVersion = 25 // 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 MessageOfTheDayURL = AssetsServerURL + "/motd.json" // VersionCheckURL represents the URL used to retrieve the latest version of Portainer VersionCheckURL = "https://api.github.com/repos/portainer/portainer/releases/latest" - // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved - ExtensionDefinitionsURL = AssetsServerURL + "/extensions-" + APIVersion + ".json" - // SupportProductsURL represents the URL where Portainer support products can be retrieved - SupportProductsURL = AssetsServerURL + "/support.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentEdgeIDHeader represent the name of the header containing the Edge ID associated to an agent/agent cluster PortainerAgentEdgeIDHeader = "X-PortainerAgent-EdgeID" + // HTTPResponseAgentPlatform represents the name of the header containing the Agent platform + HTTPResponseAgentPlatform = "Portainer-Agent-Platform" // PortainerAgentTargetHeader represent the name of the header containing the target node name PortainerAgentTargetHeader = "X-PortainerAgent-Target" // PortainerAgentSignatureHeader represent the name of the header containing the digital signature PortainerAgentSignatureHeader = "X-PortainerAgent-Signature" // PortainerAgentPublicKeyHeader represent the name of the header containing the public key PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey" + // PortainerAgentKubernetesSATokenHeader represent the name of the header containing a Kubernetes SA token + PortainerAgentKubernetesSATokenHeader = "X-PortainerAgent-SA-Token" // PortainerAgentSignatureMessage represents the message used to create a digital signature // to be used when communicating with an agent PortainerAgentSignatureMessage = "Portainer-App" - // ExtensionServer represents the server used by Portainer to communicate with extensions - 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" + // DefaultTemplatesURL represents the URL to the official templates supported by Portainer + DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultUserSessionTimeout = "8h" ) const ( @@ -1053,6 +1163,32 @@ const ( AuthenticationOAuth ) +const ( + _ AgentPlatform = iota + // AgentPlatformDocker represent the Docker platform (Standalone/Swarm) + AgentPlatformDocker + // AgentPlatformKubernetes represent the Kubernetes platform + AgentPlatformKubernetes +) + +const ( + _ EdgeJobLogsStatus = iota + // EdgeJobLogsStatusIdle represents an idle log collection job + EdgeJobLogsStatusIdle + // EdgeJobLogsStatusPending represents a pending log collection job + EdgeJobLogsStatusPending + // EdgeJobLogsStatusCollected represents a completed log collection job + EdgeJobLogsStatusCollected +) + +const ( + _ CustomTemplatePlatform = iota + // CustomTemplatePlatformLinux represents a custom template for linux + CustomTemplatePlatformLinux + // CustomTemplatePlatformWindows represents a custom template for windows + CustomTemplatePlatformWindows +) + const ( _ EdgeStackStatusType = iota //StatusOk represents a successfully deployed edge stack @@ -1085,30 +1221,20 @@ const ( 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 + // EdgeAgentOnDockerEnvironment represents an endpoint connected to an Edge agent deployed on a Docker environment + EdgeAgentOnDockerEnvironment + // KubernetesLocalEnvironment represents an endpoint connected to a local Kubernetes environment + KubernetesLocalEnvironment + // AgentOnKubernetesEnvironment represents an endpoint connected to a Portainer agent deployed on a Kubernetes environment + AgentOnKubernetesEnvironment + // EdgeAgentOnKubernetesEnvironment represents an endpoint connected to an Edge agent deployed on a Kubernetes environment + EdgeAgentOnKubernetesEnvironment ) 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 + SnapshotJobType = 2 ) const ( @@ -1153,6 +1279,8 @@ const ( StackResourceControl // ConfigResourceControl represents a resource control associated to a Docker config ConfigResourceControl + // CustomTemplateResourceControl represents a resource control associated to a custom template + CustomTemplateResourceControl ) const ( @@ -1161,6 +1289,15 @@ const ( DockerSwarmStack // DockerComposeStack represents a stack managed via docker-compose DockerComposeStack + // KubernetesStack represents a stack managed via kubectl + KubernetesStack +) + +// StackStatus represents a status for a stack +const ( + _ StackStatus = iota + StackStatusActive + StackStatusInactive ) const ( diff --git a/api/swagger.yaml b/api/swagger.yaml index 6e39642e9..87ba015f4 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1,5 +1,5 @@ --- -swagger: "2.0" +swagger: '2.0' info: description: | Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API. @@ -54,4729 +54,4531 @@ 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.24.0" - title: "Portainer API" + version: '2.0.0' + title: 'Portainer API' contact: - email: "info@portainer.io" -host: "portainer.domain" -basePath: "/api" + email: 'info@portainer.io' +host: 'portainer.domain' +basePath: '/api' tags: -- name: "auth" - description: "Authenticate against Portainer HTTP API" -- name: "dockerhub" - description: "Manage how Portainer connects to the DockerHub" -- name: "endpoints" - description: "Manage Docker environments" -- name: "endpoint_groups" - description: "Manage endpoint groups" -- name: "extensions" - description: "Manage extensions" -- name: "registries" - description: "Manage Docker registries" -- name: "resource_controls" - description: "Manage access control on Docker resources" -- name: "roles" - description: "Manage roles" -- name: "settings" - description: "Manage Portainer settings" -- name: "status" - description: "Information about the Portainer instance" -- name: "stacks" - description: "Manage Docker stacks" -- name: "users" - description: "Manage users" -- name: "tags" - description: "Manage tags" -- name: "teams" - description: "Manage teams" -- name: "team_memberships" - description: "Manage team memberships" -- name: "templates" - description: "Manage App Templates" -- name: "stacks" - description: "Manage stacks" -- name: "upload" - description: "Upload files" -- name: "websocket" - description: "Create exec sessions using websockets" + - name: 'auth' + description: 'Authenticate against Portainer HTTP API' + - name: 'dockerhub' + description: 'Manage how Portainer connects to the DockerHub' + - name: 'endpoints' + description: 'Manage Docker environments' + - name: 'endpoint_groups' + description: 'Manage endpoint groups' + - name: 'extensions' + description: 'Manage extensions' + - name: 'registries' + description: 'Manage Docker registries' + - name: 'resource_controls' + description: 'Manage access control on Docker resources' + - name: 'roles' + description: 'Manage roles' + - name: 'settings' + description: 'Manage Portainer settings' + - name: 'status' + description: 'Information about the Portainer instance' + - name: 'stacks' + description: 'Manage Docker stacks' + - name: 'users' + description: 'Manage users' + - name: 'tags' + description: 'Manage tags' + - name: 'teams' + description: 'Manage teams' + - name: 'team_memberships' + description: 'Manage team memberships' + - name: 'templates' + description: 'Manage App Templates' + - name: 'stacks' + description: 'Manage stacks' + - name: 'upload' + description: 'Upload files' + - name: 'websocket' + description: 'Create exec sessions using websockets' schemes: -- "http" -- "https" + - 'http' + - 'https' paths: /auth: post: tags: - - "auth" - summary: "Authenticate a user" + - 'auth' + summary: 'Authenticate a user' description: | Use this endpoint to authenticate against Portainer using a username and password. **Access policy**: public - operationId: "AuthenticateUser" + operationId: 'AuthenticateUser' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' parameters: - - in: "body" - name: "body" - description: "Credentials used for authentication" - required: true - schema: - $ref: "#/definitions/AuthenticateUserRequest" + - in: 'body' + name: 'body' + description: 'Credentials used for authentication' + required: true + schema: + $ref: '#/definitions/AuthenticateUserRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/AuthenticateUserResponse" + $ref: '#/definitions/AuthenticateUserResponse' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid credentials" + err: 'Invalid credentials' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "Authentication disabled" + description: 'Authentication disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Authentication is disabled" + err: 'Authentication is disabled' /dockerhub: get: tags: - - "dockerhub" - summary: "Retrieve DockerHub information" + - 'dockerhub' + summary: 'Retrieve DockerHub information' description: | Use this endpoint to retrieve the information used to connect to the DockerHub **Access policy**: authenticated - operationId: "DockerHubInspect" + operationId: 'DockerHubInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/DockerHubSubset" + $ref: '#/definitions/DockerHubSubset' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "dockerhub" - summary: "Update DockerHub information" + - 'dockerhub' + summary: 'Update DockerHub information' description: | Use this endpoint to update the information used to connect to the DockerHub **Access policy**: administrator - operationId: "DockerHubUpdate" + operationId: 'DockerHubUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "DockerHub information" - required: true - schema: - $ref: "#/definitions/DockerHubUpdateRequest" + - in: 'body' + name: 'body' + description: 'DockerHub information' + required: true + schema: + $ref: '#/definitions/DockerHubUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/DockerHub" + $ref: '#/definitions/DockerHub' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /endpoints: get: tags: - - "endpoints" - summary: "List endpoints" + - 'endpoints' + summary: 'List endpoints' description: | List all endpoints based on the current user authorizations. Will return all endpoints if using an administrator account otherwise it will only return authorized endpoints. **Access policy**: restricted - operationId: "EndpointList" + operationId: 'EndpointList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/EndpointListResponse" + $ref: '#/definitions/EndpointListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "endpoints" - summary: "Create a new endpoint" + - 'endpoints' + summary: 'Create a new endpoint' description: | Create a new endpoint that will be used to manage a Docker environment. **Access policy**: administrator - operationId: "EndpointCreate" + operationId: 'EndpointCreate' consumes: - - "multipart/form-data" + - 'multipart/form-data' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "Name" - in: "formData" - type: "string" - description: "Name that will be used to identify this endpoint (example: my-endpoint)" - required: true - - name: "EndpointType" - in: "formData" - type: "integer" - description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)" - required: true - - name: "URL" - in: "formData" - type: "string" - description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375).\ - \ Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)" - - name: "PublicURL" - in: "formData" - type: "string" - description: "URL or IP address where exposed containers will be reachable.\ - \ Defaults to URL if not specified (example: docker.mydomain.tld:2375)" - - name: "GroupID" - in: "formData" - type: "string" - description: "Endpoint group identifier. If not specified will default to 1 (unassigned)." - - name: "TLS" - in: "formData" - type: "string" - description: "Require TLS to connect against this endpoint (example: true)" - - name: "TLSSkipVerify" - in: "formData" - type: "string" - description: "Skip server verification when using TLS (example: false)" - - name: "TLSSkipClientVerify" - in: "formData" - type: "string" - description: "Skip client verification when using TLS (example: false)" - - name: "TLSCACertFile" - in: "formData" - type: "file" - description: "TLS CA certificate file" - - name: "TLSCertFile" - in: "formData" - type: "file" - description: "TLS client certificate file" - - name: "TLSKeyFile" - in: "formData" - type: "file" - description: "TLS client key file" - - name: "AzureApplicationID" - in: "formData" - type: "string" - description: "Azure application ID. Required if endpoint type is set to 3" - - name: "AzureTenantID" - in: "formData" - type: "string" - description: "Azure tenant ID. Required if endpoint type is set to 3" - - name: "AzureAuthenticationKey" - in: "formData" - type: "string" - description: "Azure authentication key. Required if endpoint type is set to 3" + - name: 'Name' + in: 'formData' + type: 'string' + description: 'Name that will be used to identify this endpoint (example: my-endpoint)' + required: true + - name: 'EndpointType' + in: 'formData' + type: 'integer' + description: 'Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)' + required: true + - name: 'URL' + in: 'formData' + type: 'string' + description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375).\ + \ Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)" + - name: 'PublicURL' + in: 'formData' + type: 'string' + description: "URL or IP address where exposed containers will be reachable.\ + \ Defaults to URL if not specified (example: docker.mydomain.tld:2375)" + - name: 'GroupID' + in: 'formData' + type: 'string' + description: 'Endpoint group identifier. If not specified will default to 1 (unassigned).' + - name: 'TLS' + in: 'formData' + type: 'string' + description: 'Require TLS to connect against this endpoint (example: true)' + - name: 'TLSSkipVerify' + in: 'formData' + type: 'string' + description: 'Skip server verification when using TLS (example: false)' + - name: 'TLSSkipClientVerify' + in: 'formData' + type: 'string' + description: 'Skip client verification when using TLS (example: false)' + - name: 'TLSCACertFile' + in: 'formData' + type: 'file' + description: 'TLS CA certificate file' + - name: 'TLSCertFile' + in: 'formData' + type: 'file' + description: 'TLS client certificate file' + - name: 'TLSKeyFile' + in: 'formData' + type: 'file' + description: 'TLS client key file' + - name: 'AzureApplicationID' + in: 'formData' + type: 'string' + description: 'Azure application ID. Required if endpoint type is set to 3' + - name: 'AzureTenantID' + in: 'formData' + type: 'string' + description: 'Azure tenant ID. Required if endpoint type is set to 3' + - name: 'AzureAuthenticationKey' + in: 'formData' + type: 'string' + description: 'Azure authentication key. Required if endpoint type is set to 3' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Endpoint" + $ref: '#/definitions/Endpoint' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "Endpoint management disabled" + description: 'Endpoint management disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint management is disabled" + err: 'Endpoint management is disabled' /endpoints/{id}: get: tags: - - "endpoints" - summary: "Inspect an endpoint" + - 'endpoints' + summary: 'Inspect an endpoint' description: | Retrieve details abount an endpoint. **Access policy**: restricted - operationId: "EndpointInspect" + operationId: 'EndpointInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Endpoint" + $ref: '#/definitions/Endpoint' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "Endpoint not found" + description: 'Endpoint not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "endpoints" - summary: "Update an endpoint" + - 'endpoints' + summary: 'Update an endpoint' description: | Update an endpoint. **Access policy**: administrator - operationId: "EndpointUpdate" + operationId: 'EndpointUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Endpoint details" - required: true - schema: - $ref: "#/definitions/EndpointUpdateRequest" + - name: 'id' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Endpoint details' + required: true + schema: + $ref: '#/definitions/EndpointUpdateRequest' responses: 200: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "Endpoint not found" + description: 'Endpoint not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "Endpoint management disabled" + description: 'Endpoint management disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint management is disabled" + err: 'Endpoint management is disabled' delete: tags: - - "endpoints" - summary: "Remove an endpoint" + - 'endpoints' + summary: 'Remove an endpoint' description: | Remove an endpoint. **Access policy**: administrator - operationId: "EndpointDelete" + operationId: 'EndpointDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "Endpoint not found" + description: 'Endpoint not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "Endpoint management disabled" + description: 'Endpoint management disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint management is disabled" + err: 'Endpoint management is disabled' /endpoints/{id}/job: post: tags: - - "endpoints" - summary: "Execute a job on the endpoint host" + - 'endpoints' + summary: 'Execute a job on the endpoint host' description: | Execute a job (script) on the underlying host of the endpoint. **Access policy**: administrator - operationId: "EndpointJob" + operationId: 'EndpointJob' consumes: - - "multipart/form-data" + - 'multipart/form-data' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - - name: "method" - in: "query" - description: "Job execution method. Possible values: file or string." - required: true - type: "string" - - name: "nodeName" - in: "query" - description: "Optional. Hostname of a node when targeting a Portainer agent cluster." - required: true - type: "string" - - in: "body" - name: "body" - description: "Job details. Required when method equals string." - required: true - schema: - $ref: "#/definitions/EndpointJobRequest" - - name: "Image" - in: "formData" - type: "string" - description: "Container image which will be used to execute the job. Required when method equals file." - - name: "file" - in: "formData" - type: "file" - description: "Job script file. Required when method equals file." + - name: 'id' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' + - name: 'method' + in: 'query' + description: 'Job execution method. Possible values: file or string.' + required: true + type: 'string' + - name: 'nodeName' + in: 'query' + description: 'Optional. Hostname of a node when targeting a Portainer agent cluster.' + required: true + type: 'string' + - in: 'body' + name: 'body' + description: 'Job details. Required when method equals string.' + required: true + schema: + $ref: '#/definitions/EndpointJobRequest' + - name: 'Image' + in: 'formData' + type: 'string' + description: 'Container image which will be used to execute the job. Required when method equals file.' + - name: 'file' + in: 'formData' + type: 'file' + description: 'Job script file. Required when method equals file.' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Endpoint" + $ref: '#/definitions/Endpoint' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Endpoint not found" + description: 'Endpoint not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /endpoint_groups: get: tags: - - "endpoint_groups" - summary: "List endpoint groups" + - 'endpoint_groups' + summary: 'List endpoint groups' description: | List all endpoint groups based on the current user authorizations. Will return all endpoint groups if using an administrator account otherwise it will only return authorized endpoint groups. **Access policy**: restricted - operationId: "EndpointGroupList" + operationId: 'EndpointGroupList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/EndpointGroupListResponse" + $ref: '#/definitions/EndpointGroupListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "endpoint_groups" - summary: "Create a new endpoint" + - 'endpoint_groups' + summary: 'Create a new endpoint' description: | Create a new endpoint group. **Access policy**: administrator - operationId: "EndpointGroupCreate" + operationId: 'EndpointGroupCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Registry details" - required: true - schema: - $ref: "#/definitions/EndpointGroupCreateRequest" + - in: 'body' + name: 'body' + description: 'Registry details' + required: true + schema: + $ref: '#/definitions/EndpointGroupCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/EndpointGroup" + $ref: '#/definitions/EndpointGroup' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /endpoint_groups/{id}: get: tags: - - "endpoint_groups" - summary: "Inspect an endpoint group" + - 'endpoint_groups' + summary: 'Inspect an endpoint group' description: | Retrieve details abount an endpoint group. **Access policy**: administrator - operationId: "EndpointGroupInspect" + operationId: 'EndpointGroupInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Endpoint group identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Endpoint group identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/EndpointGroup" + $ref: '#/definitions/EndpointGroup' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "EndpointGroup not found" + description: 'EndpointGroup not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup not found" + err: 'EndpointGroup not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "endpoint_groups" - summary: "Update an endpoint group" + - 'endpoint_groups' + summary: 'Update an endpoint group' description: | Update an endpoint group. **Access policy**: administrator - operationId: "EndpointGroupUpdate" + operationId: 'EndpointGroupUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "EndpointGroup identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "EndpointGroup details" - required: true - schema: - $ref: "#/definitions/EndpointGroupUpdateRequest" + - name: 'id' + in: 'path' + description: 'EndpointGroup identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'EndpointGroup details' + required: true + schema: + $ref: '#/definitions/EndpointGroupUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/EndpointGroup" + $ref: '#/definitions/EndpointGroup' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "EndpointGroup not found" + description: 'EndpointGroup not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup not found" + err: 'EndpointGroup not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "EndpointGroup management disabled" + description: 'EndpointGroup management disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup management is disabled" + err: 'EndpointGroup management is disabled' delete: tags: - - "endpoint_groups" - summary: "Remove an endpoint group" + - 'endpoint_groups' + summary: 'Remove an endpoint group' description: | Remove an endpoint group. **Access policy**: administrator - operationId: "EndpointGroupDelete" + operationId: 'EndpointGroupDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "EndpointGroup identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'EndpointGroup identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "EndpointGroup not found" + description: 'EndpointGroup not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup not found" + err: 'EndpointGroup not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 503: - description: "EndpointGroup management disabled" + description: 'EndpointGroup management disabled' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup management is disabled" + err: 'EndpointGroup management is disabled' /endpoint_groups/{id}/endpoints/{endpointId}: put: tags: - - "endpoint_groups" - summary: "Add an endpoint to an endpoint group" + - 'endpoint_groups' + summary: 'Add an endpoint to an endpoint group' description: | Add an endpoint to an endpoint group **Access policy**: administrator - operationId: "EndpointGroupAddEndpoint" + operationId: 'EndpointGroupAddEndpoint' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "EndpointGroup identifier" - required: true - type: "integer" - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'EndpointGroup identifier' + required: true + type: 'integer' + - name: 'endpointId' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "EndpointGroup not found" + description: 'EndpointGroup not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup not found" + err: 'EndpointGroup not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "endpoint_groups" - summary: "Remove an endpoint group" + - 'endpoint_groups' + summary: 'Remove an endpoint group' description: | Remove an endpoint group. **Access policy**: administrator - operationId: "EndpointGroupDeleteEndpoint" + operationId: 'EndpointGroupDeleteEndpoint' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "EndpointGroup identifier" - required: true - type: "integer" - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'EndpointGroup identifier' + required: true + type: 'integer' + - name: 'endpointId' + in: 'path' + description: 'Endpoint identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "EndpointGroup not found" + description: 'EndpointGroup not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "EndpointGroup not found" + err: 'EndpointGroup not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" - /extensions: - get: - tags: - - "extensions" - summary: "List extensions" - description: | - List all extensions registered inside Portainer. If the store parameter is set to true, - will retrieve extensions details from the online repository. - **Access policy**: administrator - operationId: "ExtensionList" - produces: - - "application/json" - security: - - jwt: [] - parameters: - - name: "store" - in: "query" - description: "Retrieve online information about extensions. Possible values: true or false." - required: false - type: "boolean" - responses: - 200: - description: "Success" - schema: - $ref: "#/definitions/ExtensionListResponse" - 500: - description: "Server error" - schema: - $ref: "#/definitions/GenericError" - post: - tags: - - "extensions" - summary: "Enable an extension" - description: | - Enable an extension. - **Access policy**: administrator - operationId: "ExtensionCreate" - consumes: - - "application/json" - produces: - - "application/json" - security: - - jwt: [] - parameters: - - in: "body" - name: "body" - description: "Extension details" - required: true - schema: - $ref: "#/definitions/ExtensionCreateRequest" - responses: - 204: - description: "Success" - 400: - description: "Invalid request" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Invalid request data format" - 500: - description: "Server error" - schema: - $ref: "#/definitions/GenericError" - /extensions/{id}: - get: - tags: - - "extensions" - summary: "Inspect an extension" - description: | - Retrieve details abount an extension. - **Access policy**: administrator - operationId: "ExtensionInspect" - produces: - - "application/json" - security: - - jwt: [] - parameters: - - name: "id" - in: "path" - description: "extension identifier" - required: true - type: "integer" - responses: - 200: - description: "Success" - schema: - $ref: "#/definitions/Extension" - 400: - description: "Invalid request" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Invalid request" - 404: - description: "Extension not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Extension not found" - 500: - description: "Server error" - schema: - $ref: "#/definitions/GenericError" - put: - tags: - - "extensions" - summary: "Update an extension" - description: | - Update an extension to a specific version of the extension. - **Access policy**: administrator - operationId: "ExtensionUpdate" - consumes: - - "application/json" - produces: - - "application/json" - security: - - jwt: [] - parameters: - - name: "id" - in: "path" - description: "Extension identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Extension details" - required: true - schema: - $ref: "#/definitions/ExtensionUpdateRequest" - responses: - 204: - description: "Success" - 400: - description: "Invalid request" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Invalid request data format" - 404: - description: "Extension not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Extension not found" - 500: - description: "Server error" - schema: - $ref: "#/definitions/GenericError" - delete: - tags: - - "extensions" - summary: "Disable an extension" - description: | - Disable an extension. - **Access policy**: administrator - operationId: "ExtensionDelete" - security: - - jwt: [] - parameters: - - name: "id" - in: "path" - description: "Extension identifier" - required: true - type: "integer" - responses: - 204: - description: "Success" - 400: - description: "Invalid request" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Invalid request" - 404: - description: "Extension not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Extension not found" - 500: - description: "Server error" - schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /registries: get: tags: - - "registries" - summary: "List registries" + - 'registries' + summary: 'List registries' description: | List all registries based on the current user authorizations. Will return all registries if using an administrator account otherwise it will only return authorized registries. **Access policy**: restricted - operationId: "RegistryList" + operationId: 'RegistryList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/RegistryListResponse" + $ref: '#/definitions/RegistryListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "registries" - summary: "Create a new registry" + - 'registries' + summary: 'Create a new registry' description: | Create a new registry. **Access policy**: administrator - operationId: "RegistryCreate" + operationId: 'RegistryCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Registry details" - required: true - schema: - $ref: "#/definitions/RegistryCreateRequest" + - in: 'body' + name: 'body' + description: 'Registry details' + required: true + schema: + $ref: '#/definitions/RegistryCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Registry" + $ref: '#/definitions/Registry' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 409: - description: "Registry already exists" + description: 'Registry already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "A registry is already defined for this URL" + err: 'A registry is already defined for this URL' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /registries/{id}: get: tags: - - "registries" - summary: "Inspect a registry" + - 'registries' + summary: 'Inspect a registry' description: | Retrieve details about a registry. **Access policy**: administrator - operationId: "RegistryInspect" + operationId: 'RegistryInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Registry identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Registry identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Registry" + $ref: '#/definitions/Registry' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "Registry not found" + description: 'Registry not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Registry not found" + err: 'Registry not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "registries" - summary: "Update a registry" + - 'registries' + summary: 'Update a registry' description: | Update a registry. **Access policy**: administrator - operationId: "RegistryUpdate" + operationId: 'RegistryUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Registry identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Registry details" - required: true - schema: - $ref: "#/definitions/RegistryUpdateRequest" + - name: 'id' + in: 'path' + description: 'Registry identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Registry details' + required: true + schema: + $ref: '#/definitions/RegistryUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Registry" + $ref: '#/definitions/Registry' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "Registry not found" + description: 'Registry not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 409: - description: "Registry already exists" + description: 'Registry already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "A registry is already defined for this URL" + err: 'A registry is already defined for this URL' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "registries" - summary: "Remove a registry" + - 'registries' + summary: 'Remove a registry' description: | Remove a registry. **Access policy**: administrator - operationId: "RegistryDelete" + operationId: 'RegistryDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Registry identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Registry identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "Registry not found" + description: 'Registry not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Registry not found" + err: 'Registry not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /resource_controls: post: tags: - - "resource_controls" - summary: "Create a new resource control" + - 'resource_controls' + summary: 'Create a new resource control' description: | Create a new resource control to restrict access to a Docker resource. **Access policy**: administrator - operationId: "ResourceControlCreate" + operationId: 'ResourceControlCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Resource control details" - required: true - schema: - $ref: "#/definitions/ResourceControlCreateRequest" + - in: 'body' + name: 'body' + description: 'Resource control details' + required: true + schema: + $ref: '#/definitions/ResourceControlCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/ResourceControl" + $ref: '#/definitions/ResourceControl' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 409: - description: "Resource control already exists" + description: 'Resource control already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "A resource control is already applied on this resource" + err: 'A resource control is already applied on this resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /resource_controls/{id}: put: tags: - - "resource_controls" - summary: "Update a resource control" + - 'resource_controls' + summary: 'Update a resource control' description: | Update a resource control. **Access policy**: restricted - operationId: "ResourceControlUpdate" + operationId: 'ResourceControlUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Resource control identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Resource control details" - required: true - schema: - $ref: "#/definitions/ResourceControlUpdateRequest" + - name: 'id' + in: 'path' + description: 'Resource control identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Resource control details' + required: true + schema: + $ref: '#/definitions/ResourceControlUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/ResourceControl" + $ref: '#/definitions/ResourceControl' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Resource control not found" + description: 'Resource control not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Resource control not found" + err: 'Resource control not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "resource_controls" - summary: "Remove a resource control" + - 'resource_controls' + summary: 'Remove a resource control' description: | Remove a resource control. **Access policy**: administrator - operationId: "ResourceControlDelete" + operationId: 'ResourceControlDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Resource control identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Resource control identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Resource control not found" + description: 'Resource control not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Resource control not found" + err: 'Resource control not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /roles: get: tags: - - "roles" - summary: "List roles" + - 'roles' + summary: 'List roles' description: | List all roles available for use with the RBAC extension. **Access policy**: administrator - operationId: "RoleList" + operationId: 'RoleList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/RoleListResponse" + $ref: '#/definitions/RoleListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /settings: get: tags: - - "settings" - summary: "Retrieve Portainer settings" + - 'settings' + summary: 'Retrieve Portainer settings' description: | Retrieve Portainer settings. **Access policy**: administrator - operationId: "SettingsInspect" + operationId: 'SettingsInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Settings" + $ref: '#/definitions/Settings' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "settings" - summary: "Update Portainer settings" + - 'settings' + summary: 'Update Portainer settings' description: | Update Portainer settings. **Access policy**: administrator - operationId: "SettingsUpdate" + operationId: 'SettingsUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "New settings" - required: true - schema: - $ref: "#/definitions/SettingsUpdateRequest" + - in: 'body' + name: 'body' + description: 'New settings' + required: true + schema: + $ref: '#/definitions/SettingsUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Settings" + $ref: '#/definitions/Settings' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /settings/public: get: tags: - - "settings" - summary: "Retrieve Portainer public settings" + - 'settings' + summary: 'Retrieve Portainer public settings' description: | Retrieve public settings. Returns a small set of settings that are not reserved to administrators only. **Access policy**: public - operationId: "PublicSettingsInspect" + operationId: 'PublicSettingsInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/PublicSettingsInspectResponse" + $ref: '#/definitions/PublicSettingsInspectResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /settings/authentication/checkLDAP: put: tags: - - "settings" - summary: "Test LDAP connectivity" + - 'settings' + summary: 'Test LDAP connectivity' description: | Test LDAP connectivity using LDAP details. **Access policy**: administrator - operationId: "SettingsLDAPCheck" + operationId: 'SettingsLDAPCheck' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "LDAP settings" - required: true - schema: - $ref: "#/definitions/SettingsLDAPCheckRequest" + - in: 'body' + name: 'body' + description: 'LDAP settings' + required: true + schema: + $ref: '#/definitions/SettingsLDAPCheckRequest' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /status: get: tags: - - "status" - summary: "Check Portainer status" + - 'status' + summary: 'Check Portainer status' description: | Retrieve Portainer status. **Access policy**: public - operationId: "StatusInspect" + operationId: 'StatusInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Status" + $ref: '#/definitions/Status' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /stacks: get: tags: - - "stacks" - summary: "List stacks" + - 'stacks' + summary: 'List stacks' description: | List all stacks based on the current user authorizations. Will return all stacks if using an administrator account otherwise it will only return the list of stacks the user have access to. **Access policy**: restricted - operationId: "StackList" + operationId: 'StackList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "filters" - in: "query" - description: | - Filters to process on the stack list. Encoded as JSON (a map[string]string). - For example, {"SwarmID": "jpofkc0i9uo9wtx1zesuk649w"} will only return stacks that are part - of the specified Swarm cluster. Available filters: EndpointID, SwarmID. - type: "string" + - name: 'filters' + in: 'query' + description: | + Filters to process on the stack list. Encoded as JSON (a map[string]string). + For example, {"SwarmID": "jpofkc0i9uo9wtx1zesuk649w"} will only return stacks that are part + of the specified Swarm cluster. Available filters: EndpointID, SwarmID. + type: 'string' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/StackListResponse" + $ref: '#/definitions/StackListResponse' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "stacks" - summary: "Deploy a new stack" + - 'stacks' + summary: 'Deploy a new stack' description: | Deploy a new stack into a Docker environment specified via the endpoint identifier. **Access policy**: restricted - operationId: "StackCreate" + operationId: 'StackCreate' consumes: - - "multipart/form-data" + - 'multipart/form-data' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "type" - in: "query" - description: "Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack)." - required: true - type: "integer" - - name: "method" - in: "query" - description: "Stack deployment method. Possible values: file, string or repository." - required: true - type: "string" - - name: "endpointId" - in: "query" - description: "Identifier of the endpoint that will be used to deploy the stack." - required: true - type: "integer" - - in: "body" - name: "body" - description: "Stack details. Required when method equals string or repository." - schema: - $ref: "#/definitions/StackCreateRequest" - - name: "Name" - in: "formData" - type: "string" - description: "Name of the stack. Required when method equals file." - - name: "EndpointID" - in: "formData" - type: "string" - description: "Endpoint identifier used to deploy the stack. Required when method equals file." - - name: "SwarmID" - in: "formData" - type: "string" - description: "Swarm cluster identifier. Required when method equals file and type equals 1." - - name: "file" - in: "formData" - type: "file" - description: "Stack file. Required when method equals file." - - name: "Env" - in: "formData" - type: "string" - description: "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." + - name: 'type' + in: 'query' + description: 'Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack).' + required: true + type: 'integer' + - name: 'method' + in: 'query' + description: 'Stack deployment method. Possible values: file, string or repository.' + required: true + type: 'string' + - name: 'endpointId' + in: 'query' + description: 'Identifier of the endpoint that will be used to deploy the stack.' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Stack details. Required when method equals string or repository.' + schema: + $ref: '#/definitions/StackCreateRequest' + - name: 'Name' + in: 'formData' + type: 'string' + description: 'Name of the stack. Required when method equals file.' + - name: 'EndpointID' + in: 'formData' + type: 'string' + description: 'Endpoint identifier used to deploy the stack. Required when method equals file.' + - name: 'SwarmID' + in: 'formData' + type: 'string' + description: 'Swarm cluster identifier. Required when method equals file and type equals 1.' + - name: 'file' + in: 'formData' + type: 'file' + description: 'Stack file. Required when method equals file.' + - name: 'Env' + in: 'formData' + type: 'string' + description: "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Stack" + $ref: '#/definitions/Stack' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Endpoint not found" + description: 'Endpoint not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Endpoint not found" + err: 'Endpoint not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /stacks/{id}: get: tags: - - "stacks" - summary: "Inspect a stack" + - 'stacks' + summary: 'Inspect a stack' description: | Retrieve details about a stack. **Access policy**: restricted - operationId: "StackInspect" + operationId: 'StackInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Stack identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Stack identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Stack" + $ref: '#/definitions/Stack' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Stack not found" + description: 'Stack not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Stack not found" + err: 'Stack not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "stacks" - summary: "Update a stack" + - 'stacks' + summary: 'Update a stack' description: | Update a stack. **Access policy**: restricted - operationId: "StackUpdate" + operationId: 'StackUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Stack identifier" - required: true - type: "integer" - - name: "endpointId" - in: "query" - description: "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this \ - optional parameter to set the endpoint identifier used by the stack." - type: "integer" - - in: "body" - name: "body" - description: "Stack details" - required: true - schema: - $ref: "#/definitions/StackUpdateRequest" + - name: 'id' + in: 'path' + description: 'Stack identifier' + required: true + type: 'integer' + - name: 'endpointId' + in: 'query' + description: "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this \ + optional parameter to set the endpoint identifier used by the stack." + type: 'integer' + - in: 'body' + name: 'body' + description: 'Stack details' + required: true + schema: + $ref: '#/definitions/StackUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Stack" + $ref: '#/definitions/Stack' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Stack not found" + description: 'Stack not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Stack not found" + err: 'Stack not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "stacks" - summary: "Remove a stack" + - 'stacks' + summary: 'Remove a stack' description: | Remove a stack. **Access policy**: restricted - operationId: "StackDelete" + operationId: 'StackDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Stack identifier" - required: true - type: "integer" - - name: "external" - in: "query" - description: "Set to true to delete an external stack. Only external Swarm stacks are supported." - type: "boolean" - - name: "endpointId" - in: "query" - description: "Endpoint identifier used to remove an external stack (required when external is set to true)" - type: "string" + - name: 'id' + in: 'path' + description: 'Stack identifier' + required: true + type: 'integer' + - name: 'external' + in: 'query' + description: 'Set to true to delete an external stack. Only external Swarm stacks are supported.' + type: 'boolean' + - name: 'endpointId' + in: 'query' + description: 'Endpoint identifier used to remove an external stack (required when external is set to true)' + type: 'string' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Stack not found" + description: 'Stack not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Stack not found" + err: 'Stack not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /stacks/{id}/file: get: tags: - - "stacks" - summary: "Retrieve the content of the Stack file for the specified stack" + - 'stacks' + summary: 'Retrieve the content of the Stack file for the specified stack' description: | Get Stack file content. **Access policy**: restricted - operationId: "StackFileInspect" + operationId: 'StackFileInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Stack identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Stack identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/StackFileInspectResponse" + $ref: '#/definitions/StackFileInspectResponse' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Stack not found" + description: 'Stack not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Stack not found" + err: 'Stack not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /stacks/{id}/migrate: post: tags: - - "stacks" - summary: "Migrate a stack to another endpoint" + - 'stacks' + summary: 'Migrate a stack to another endpoint' description: | Migrate a stack from an endpoint to another endpoint. It will re-create the stack inside the target endpoint before removing the original stack. **Access policy**: restricted - operationId: "StackMigrate" + operationId: 'StackMigrate' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Stack identifier" - required: true - type: "integer" - - name: "endpointId" - in: "query" - description: "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this \ - optional parameter to set the endpoint identifier used by the stack." - type: "integer" - - in: "body" - name: "body" - description: "Stack migration details." - schema: - $ref: "#/definitions/StackMigrateRequest" + - name: 'id' + in: 'path' + description: 'Stack identifier' + required: true + type: 'integer' + - name: 'endpointId' + in: 'query' + description: "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this \ + optional parameter to set the endpoint identifier used by the stack." + type: 'integer' + - in: 'body' + name: 'body' + description: 'Stack migration details.' + schema: + $ref: '#/definitions/StackMigrateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Stack" + $ref: '#/definitions/Stack' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' 404: - description: "Stack not found" + description: 'Stack not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Stack not found" + err: 'Stack not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users: get: tags: - - "users" - summary: "List users" + - 'users' + summary: 'List users' description: | List Portainer users. Non-administrator users will only be able to list other non-administrator user accounts. **Access policy**: restricted - operationId: "UserList" + operationId: 'UserList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/UserListResponse" + $ref: '#/definitions/UserListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "users" - summary: "Create a new user" + - 'users' + summary: 'Create a new user' description: | Create a new Portainer user. Only team leaders and administrators can create users. Only administrators can create an administrator user account. **Access policy**: restricted - operationId: "UserCreate" + operationId: 'UserCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "User details" - required: true - schema: - $ref: "#/definitions/UserCreateRequest" + - in: 'body' + name: 'body' + description: 'User details' + required: true + schema: + $ref: '#/definitions/UserCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/UserSubset" + $ref: '#/definitions/UserSubset' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 409: - description: "User already exists" + description: 'User already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User already exists" + err: 'User already exists' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users/{id}: get: tags: - - "users" - summary: "Inspect a user" + - 'users' + summary: 'Inspect a user' description: | Retrieve details about a user. **Access policy**: administrator - operationId: "UserInspect" + operationId: 'UserInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "User identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'User identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/User" + $ref: '#/definitions/User' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "User not found" + description: 'User not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User not found" + err: 'User not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "users" - summary: "Update a user" + - 'users' + summary: 'Update a user' description: | Update user details. A regular user account can only update his details. **Access policy**: authenticated - operationId: "UserUpdate" + operationId: 'UserUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "User identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "User details" - required: true - schema: - $ref: "#/definitions/UserUpdateRequest" + - name: 'id' + in: 'path' + description: 'User identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'User details' + required: true + schema: + $ref: '#/definitions/UserUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/User" + $ref: '#/definitions/User' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "User not found" + description: 'User not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User not found" + err: 'User not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "users" - summary: "Remove a user" + - 'users' + summary: 'Remove a user' description: | Remove a user. **Access policy**: administrator - operationId: "UserDelete" + operationId: 'UserDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "User identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'User identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "User not found" + description: 'User not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User not found" + err: 'User not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users/{id}/memberships: get: tags: - - "users" - summary: "Inspect a user memberships" + - 'users' + summary: 'Inspect a user memberships' description: | Inspect a user memberships. **Access policy**: authenticated - operationId: "UserMembershipsInspect" + operationId: 'UserMembershipsInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "User identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'User identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/UserMembershipsResponse" + $ref: '#/definitions/UserMembershipsResponse' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users/{id}/passwd: post: tags: - - "users" - summary: "Check password validity for a user" + - 'users' + summary: 'Check password validity for a user' description: | Check if the submitted password is valid for the specified user. **Access policy**: authenticated - operationId: "UserPasswordCheck" + operationId: 'UserPasswordCheck' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "User identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "User details" - required: true - schema: - $ref: "#/definitions/UserPasswordCheckRequest" + - name: 'id' + in: 'path' + description: 'User identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'User details' + required: true + schema: + $ref: '#/definitions/UserPasswordCheckRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/UserPasswordCheckResponse" + $ref: '#/definitions/UserPasswordCheckResponse' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "User not found" + description: 'User not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User not found" + err: 'User not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users/admin/check: get: tags: - - "users" - summary: "Check administrator account existence" + - 'users' + summary: 'Check administrator account existence' description: | Check if an administrator account exists in the database. **Access policy**: public - operationId: "UserAdminCheck" + operationId: 'UserAdminCheck' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 204: - description: "Success" + description: 'Success' 404: - description: "User not found" + description: 'User not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User not found" + err: 'User not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /users/admin/init: post: tags: - - "users" - summary: "Initialize administrator account" + - 'users' + summary: 'Initialize administrator account' description: | Initialize the 'admin' user account. **Access policy**: public - operationId: "UserAdminInit" + operationId: 'UserAdminInit' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "User details" - required: true - schema: - $ref: "#/definitions/UserAdminInitRequest" + - in: 'body' + name: 'body' + description: 'User details' + required: true + schema: + $ref: '#/definitions/UserAdminInitRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/User" + $ref: '#/definitions/User' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 409: - description: "Admin user already initialized" + description: 'Admin user already initialized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "User already exists" + err: 'User already exists' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /upload/tls/{certificate}: post: tags: - - "upload" - summary: "Upload TLS files" + - 'upload' + summary: 'Upload TLS files' description: | Use this endpoint to upload TLS files. **Access policy**: administrator - operationId: "UploadTLS" + operationId: 'UploadTLS' consumes: - - multipart/form-data + - multipart/form-data produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "path" - name: "certificate" - description: "TLS file type. Valid values are 'ca', 'cert' or 'key'." - required: true - type: "string" - - in: "query" - name: "folder" - description: "Folder where the TLS file will be stored. Will be created if not existing." - required: true - type: "string" - - in: "formData" - name: "file" - type: "file" - description: "The file to upload." + - in: 'path' + name: 'certificate' + description: "TLS file type. Valid values are 'ca', 'cert' or 'key'." + required: true + type: 'string' + - in: 'query' + name: 'folder' + description: 'Folder where the TLS file will be stored. Will be created if not existing.' + required: true + type: 'string' + - in: 'formData' + name: 'file' + type: 'file' + description: 'The file to upload.' responses: 200: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data" + err: 'Invalid request data' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /tags: get: tags: - - "tags" - summary: "List tags" + - 'tags' + summary: 'List tags' description: | List tags. **Access policy**: administrator - operationId: "TagList" + operationId: 'TagList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TagListResponse" + $ref: '#/definitions/TagListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "tags" - summary: "Create a new tag" + - 'tags' + summary: 'Create a new tag' description: | Create a new tag. **Access policy**: administrator - operationId: "TagCreate" + operationId: 'TagCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Tag details" - required: true - schema: - $ref: "#/definitions/TagCreateRequest" + - in: 'body' + name: 'body' + description: 'Tag details' + required: true + schema: + $ref: '#/definitions/TagCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Tag" + $ref: '#/definitions/Tag' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 409: - description: "Conflict" + description: 'Conflict' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "A tag with the specified name already exists" + err: 'A tag with the specified name already exists' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /tags/{id}: delete: tags: - - "tags" - summary: "Remove a tag" + - 'tags' + summary: 'Remove a tag' description: | Remove a tag. **Access policy**: administrator - operationId: "TagDelete" + operationId: 'TagDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Tag identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Tag identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /teams: get: tags: - - "teams" - summary: "List teams" + - 'teams' + summary: 'List teams' description: | List teams. For non-administrator users, will only list the teams they are member of. **Access policy**: restricted - operationId: "TeamList" + operationId: 'TeamList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TeamListResponse" + $ref: '#/definitions/TeamListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "teams" - summary: "Create a new team" + - 'teams' + summary: 'Create a new team' description: | Create a new team. **Access policy**: administrator - operationId: "TeamCreate" + operationId: 'TeamCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Team details" - required: true - schema: - $ref: "#/definitions/TeamCreateRequest" + - in: 'body' + name: 'body' + description: 'Team details' + required: true + schema: + $ref: '#/definitions/TeamCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Team" + $ref: '#/definitions/Team' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 409: - description: "Team already exists" + description: 'Team already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team already exists" + err: 'Team already exists' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /teams/{id}: get: tags: - - "teams" - summary: "Inspect a team" + - 'teams' + summary: 'Inspect a team' description: | Retrieve details about a team. Access is only available for administrator and leaders of that team. **Access policy**: restricted - operationId: "TeamInspect" + operationId: 'TeamInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Team identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Team identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Team" + $ref: '#/definitions/Team' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Team not found" + description: 'Team not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team not found" + err: 'Team not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "teams" - summary: "Update a team" + - 'teams' + summary: 'Update a team' description: | Update a team. **Access policy**: administrator - operationId: "TeamUpdate" + operationId: 'TeamUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Team identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Team details" - required: true - schema: - $ref: "#/definitions/TeamUpdateRequest" + - name: 'id' + in: 'path' + description: 'Team identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Team details' + required: true + schema: + $ref: '#/definitions/TeamUpdateRequest' responses: 200: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 404: - description: "Team not found" + description: 'Team not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team not found" + err: 'Team not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "teams" - summary: "Remove a team" + - 'teams' + summary: 'Remove a team' description: | Remove a team. **Access policy**: administrator - operationId: "TeamDelete" + operationId: 'TeamDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Team identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Team identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 404: - description: "Team not found" + description: 'Team not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team not found" + err: 'Team not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /teams/{id}/memberships: get: tags: - - "teams" - summary: "Inspect a team memberships" + - 'teams' + summary: 'Inspect a team memberships' description: | Inspect a team memberships. Access is only available for administrator and leaders of that team. **Access policy**: restricted - operationId: "TeamMembershipsInspect" + operationId: 'TeamMembershipsInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Team identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Team identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TeamMembershipsResponse" + $ref: '#/definitions/TeamMembershipsResponse' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /team_memberships: get: tags: - - "team_memberships" - summary: "List team memberships" + - 'team_memberships' + summary: 'List team memberships' description: | List team memberships. Access is only available to administrators and team leaders. **Access policy**: restricted - operationId: "TeamMembershipList" + operationId: 'TeamMembershipList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TeamMembershipListResponse" + $ref: '#/definitions/TeamMembershipListResponse' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "team_memberships" - summary: "Create a new team membership" + - 'team_memberships' + summary: 'Create a new team membership' description: | Create a new team memberships. Access is only available to administrators leaders of the associated team. **Access policy**: restricted - operationId: "TeamMembershipCreate" + operationId: 'TeamMembershipCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Team membership details" - required: true - schema: - $ref: "#/definitions/TeamMembershipCreateRequest" + - in: 'body' + name: 'body' + description: 'Team membership details' + required: true + schema: + $ref: '#/definitions/TeamMembershipCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TeamMembership" + $ref: '#/definitions/TeamMembership' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 409: - description: "Team membership already exists" + description: 'Team membership already exists' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team membership already exists for this user and team." + err: 'Team membership already exists for this user and team.' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /team_memberships/{id}: put: tags: - - "team_memberships" - summary: "Update a team membership" + - 'team_memberships' + summary: 'Update a team membership' description: | Update a team membership. Access is only available to administrators leaders of the associated team. **Access policy**: restricted - operationId: "TeamMembershipUpdate" + operationId: 'TeamMembershipUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Team membership identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Team membership details" - required: true - schema: - $ref: "#/definitions/TeamMembershipUpdateRequest" + - name: 'id' + in: 'path' + description: 'Team membership identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Team membership details' + required: true + schema: + $ref: '#/definitions/TeamMembershipUpdateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TeamMembership" + $ref: '#/definitions/TeamMembership' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Team membership not found" + description: 'Team membership not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team membership not found" + err: 'Team membership not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "team_memberships" - summary: "Remove a team membership" + - 'team_memberships' + summary: 'Remove a team membership' description: | Remove a team membership. Access is only available to administrators leaders of the associated team. **Access policy**: restricted - operationId: "TeamMembershipDelete" + operationId: 'TeamMembershipDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "TeamMembership identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'TeamMembership identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Team membership not found" + description: 'Team membership not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Team membership not found" + err: 'Team membership not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /templates: get: tags: - - "templates" - summary: "List available templates" + - 'templates' + summary: 'List available templates' description: | List available templates. Administrator templates will not be listed for non-administrator users. **Access policy**: restricted - operationId: "TemplateList" + operationId: 'TemplateList' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: [] responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/TemplateListResponse" + $ref: '#/definitions/TemplateListResponse' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' post: tags: - - "templates" - summary: "Create a new template" + - 'templates' + summary: 'Create a new template' description: | Create a new template. **Access policy**: administrator - operationId: "TemplateCreate" + operationId: 'TemplateCreate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - in: "body" - name: "body" - description: "Template details" - required: true - schema: - $ref: "#/definitions/TemplateCreateRequest" + - in: 'body' + name: 'body' + description: 'Template details' + required: true + schema: + $ref: '#/definitions/TemplateCreateRequest' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Template" + $ref: '#/definitions/Template' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' /templates/{id}: get: tags: - - "templates" - summary: "Inspect a template" + - 'templates' + summary: 'Inspect a template' description: | Retrieve details about a template. **Access policy**: administrator - operationId: "TemplateInspect" + operationId: 'TemplateInspect' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Template identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Template identifier' + required: true + type: 'integer' responses: 200: - description: "Success" + description: 'Success' schema: - $ref: "#/definitions/Template" + $ref: '#/definitions/Template' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Template not found" + description: 'Template not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Template not found" + err: 'Template not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' put: tags: - - "templates" - summary: "Update a template" + - 'templates' + summary: 'Update a template' description: | Update a template. **Access policy**: administrator - operationId: "TemplateUpdate" + operationId: 'TemplateUpdate' consumes: - - "application/json" + - 'application/json' produces: - - "application/json" + - 'application/json' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Template identifier" - required: true - type: "integer" - - in: "body" - name: "body" - description: "Template details" - required: true - schema: - $ref: "#/definitions/TemplateUpdateRequest" + - name: 'id' + in: 'path' + description: 'Template identifier' + required: true + type: 'integer' + - in: 'body' + name: 'body' + description: 'Template details' + required: true + schema: + $ref: '#/definitions/TemplateUpdateRequest' responses: 200: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request data format" + err: 'Invalid request data format' 403: - description: "Unauthorized" + description: 'Unauthorized' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Access denied to resource" + err: 'Access denied to resource' 404: - description: "Template not found" + description: 'Template not found' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Template not found" + err: 'Template not found' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' delete: tags: - - "templates" - summary: "Remove a template" + - 'templates' + summary: 'Remove a template' description: | Remove a template. **Access policy**: administrator - operationId: "TemplateDelete" + operationId: 'TemplateDelete' security: - - jwt: [] + - jwt: [] parameters: - - name: "id" - in: "path" - description: "Template identifier" - required: true - type: "integer" + - name: 'id' + in: 'path' + description: 'Template identifier' + required: true + type: 'integer' responses: 204: - description: "Success" + description: 'Success' 400: - description: "Invalid request" + description: 'Invalid request' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' examples: application/json: - err: "Invalid request" + err: 'Invalid request' 500: - description: "Server error" + description: 'Server error' schema: - $ref: "#/definitions/GenericError" + $ref: '#/definitions/GenericError' securityDefinitions: jwt: - type: "apiKey" - name: "Authorization" - in: "header" + type: 'apiKey' + name: 'Authorization' + in: 'header' definitions: Tag: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Tag identifier" + description: 'Tag identifier' Name: - type: "string" - example: "org/acme" - description: "Tag name" + type: 'string' + example: 'org/acme' + description: 'Tag name' Team: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' Name: - type: "string" - example: "developers" - description: "Team name" + type: 'string' + example: 'developers' + description: 'Team name' TeamMembership: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Membership identifier" + description: 'Membership identifier' UserID: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' TeamID: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' Role: - type: "integer" + type: 'integer' example: 1 - description: "Team role (1 for team leader and 2 for team member)" + description: 'Team role (1 for team leader and 2 for team member)' UserSubset: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' Username: - type: "string" - example: "bob" - description: "Username" + type: 'string' + example: 'bob' + description: 'Username' Role: - type: "integer" + type: 'integer' example: 1 - description: "User role (1 for administrator account and 2 for regular account)" + description: 'User role (1 for administrator account and 2 for regular account)' User: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' Username: - type: "string" - example: "bob" - description: "Username" + type: 'string' + example: 'bob' + description: 'Username' Password: - type: "string" - example: "passwd" - description: "Password" + type: 'string' + example: 'passwd' + description: 'Password' Role: - type: "integer" + type: 'integer' example: 1 - description: "User role (1 for administrator account and 2 for regular account)" + description: 'User role (1 for administrator account and 2 for regular account)' Status: - type: "object" + type: 'object' properties: Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication enabled" - EndpointManagement: - type: "boolean" - example: true - description: "Is endpoint management enabled" - Analytics: - type: "boolean" - example: true - description: "Is analytics enabled" + description: 'Is authentication enabled' Version: - type: "string" - example: "1.24.0" - description: "Portainer API version" + type: 'string' + example: '2.0.0' + description: 'Portainer API version' PublicSettingsInspectResponse: - type: "object" + type: 'object' properties: LogoURL: - type: "string" - example: "https://mycompany.mydomain.tld/logo.png" + type: 'string' + example: 'https://mycompany.mydomain.tld/logo.png' description: "URL to a logo that will be displayed on the login page as well\ \ as on top of the sidebar. Will use default Portainer logo when value is\ \ empty string" DisplayExternalContributors: - type: "boolean" + type: 'boolean' example: false description: "Whether to display or not external templates contributions as\ \ sub-menus in the UI." AuthenticationMethod: - type: "integer" + type: 'integer' example: 1 - description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." + description: 'Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP.' AllowBindMountsForRegularUsers: - type: "boolean" + type: 'boolean' example: false - description: "Whether non-administrator should be able to use bind mounts when creating containers" + description: 'Whether non-administrator should be able to use bind mounts when creating containers' AllowPrivilegedModeForRegularUsers: - type: "boolean" + type: 'boolean' example: true - description: "Whether non-administrator should be able to use privileged mode when creating containers" + description: 'Whether non-administrator should be able to use privileged mode when creating containers' TLSConfiguration: - type: "object" + type: 'object' properties: TLS: - type: "boolean" + type: 'boolean' example: true - description: "Use TLS" + description: 'Use TLS' TLSSkipVerify: - type: "boolean" + type: 'boolean' example: false - description: "Skip the verification of the server TLS certificate" + description: 'Skip the verification of the server TLS certificate' TLSCACertPath: - type: "string" - example: "/data/tls/ca.pem" - description: "Path to the TLS CA certificate file" + type: 'string' + example: '/data/tls/ca.pem' + description: 'Path to the TLS CA certificate file' TLSCertPath: - type: "string" - example: "/data/tls/cert.pem" - description: "Path to the TLS client certificate file" + type: 'string' + example: '/data/tls/cert.pem' + description: 'Path to the TLS client certificate file' TLSKeyPath: - type: "string" - example: "/data/tls/key.pem" - description: "Path to the TLS client key file" + type: 'string' + example: '/data/tls/key.pem' + description: 'Path to the TLS client key file' AzureCredentials: - type: "object" + type: 'object' properties: ApplicationID: - type: "string" - example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" - description: "Azure application ID" + type: 'string' + example: 'eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4' + description: 'Azure application ID' TenantID: - type: "string" - example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" - description: "Azure tenant ID" + type: 'string' + example: '34ddc78d-4fel-2358-8cc1-df84c8o839f5' + description: 'Azure tenant ID' AuthenticationKey: - type: "string" - example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" - description: "Azure authentication key" + type: 'string' + example: 'cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=' + description: 'Azure authentication key' LDAPSearchSettings: - type: "object" + type: 'object' properties: BaseDN: - type: "string" - example: "dc=ldap,dc=domain,dc=tld" - description: "The distinguished name of the element from which the LDAP server will search for users" + type: 'string' + example: 'dc=ldap,dc=domain,dc=tld' + description: 'The distinguished name of the element from which the LDAP server will search for users' Filter: - type: "string" - example: "(objectClass=account)" - description: "Optional LDAP search filter used to select user elements" + type: 'string' + example: '(objectClass=account)' + description: 'Optional LDAP search filter used to select user elements' UserNameAttribute: - type: "string" - example: "uid" - description: "LDAP attribute which denotes the username" + type: 'string' + example: 'uid' + description: 'LDAP attribute which denotes the username' LDAPGroupSearchSettings: - type: "object" + type: 'object' properties: GroupBaseDN: - type: "string" - example: "dc=ldap,dc=domain,dc=tld" - description: "The distinguished name of the element from which the LDAP server will search for groups." + type: 'string' + example: 'dc=ldap,dc=domain,dc=tld' + description: 'The distinguished name of the element from which the LDAP server will search for groups.' GroupFilter: - type: "string" - example: "(objectClass=account)" - description: "The LDAP search filter used to select group elements, optional." + type: 'string' + example: '(objectClass=account)' + description: 'The LDAP search filter used to select group elements, optional.' GroupAttribute: - type: "string" - example: "member" - description: "LDAP attribute which denotes the group membership." + type: 'string' + example: 'member' + description: 'LDAP attribute which denotes the group membership.' UserAccessPolicies: - type: "object" - description: "User access policies associated to a registry/endpoint/endpoint group. RoleID is not required for registry access policies and can be set to 0." + type: 'object' + description: 'User access policies associated to a registry/endpoint/endpoint group. RoleID is not required for registry access policies and can be set to 0.' additionalProperties: - $ref: "#/definitions/AccessPolicy" + $ref: '#/definitions/AccessPolicy' example: 1: { RoleID: 1 } 2: { RoleID: 3 } TeamAccessPolicies: - type: "object" - description: "Team access policies associated to a registry/endpoint/endpoint group. RoleID is not required for registry access policies and can be set to 0." + type: 'object' + description: 'Team access policies associated to a registry/endpoint/endpoint group. RoleID is not required for registry access policies and can be set to 0.' additionalProperties: - $ref: "#/definitions/AccessPolicy" + $ref: '#/definitions/AccessPolicy' example: 1: { RoleID: 1 } 2: { RoleID: 3 } AccessPolicy: - type: "object" + type: 'object' properties: RoleID: - type: "integer" - example: "1" - description: "Role identifier. Reference the role that will be associated to this access policy" + type: 'integer' + example: '1' + description: 'Role identifier. Reference the role that will be associated to this access policy' LDAPSettings: - type: "object" + type: 'object' properties: AnonymousMode: - type: "boolean" + type: 'boolean' example: true - description: "Enable this option if the server is configured for Anonymous access. When enabled, ReaderDN and Password will not be used." + description: 'Enable this option if the server is configured for Anonymous access. When enabled, ReaderDN and Password will not be used.' ReaderDN: - type: "string" - example: "cn=readonly-account,dc=ldap,dc=domain,dc=tld" - description: "Account that will be used to search for users" + type: 'string' + example: 'cn=readonly-account,dc=ldap,dc=domain,dc=tld' + description: 'Account that will be used to search for users' Password: - type: "string" - example: "readonly-password" - description: "Password of the account that will be used to search users" + type: 'string' + example: 'readonly-password' + description: 'Password of the account that will be used to search users' URL: - type: "string" - example: "myldap.domain.tld:389" - description: "URL or IP address of the LDAP server" + type: 'string' + example: 'myldap.domain.tld:389' + description: 'URL or IP address of the LDAP server' TLSConfig: - $ref: "#/definitions/TLSConfiguration" + $ref: '#/definitions/TLSConfiguration' StartTLS: - type: "boolean" + type: 'boolean' example: true - description: "Whether LDAP connection should use StartTLS" + description: 'Whether LDAP connection should use StartTLS' SearchSettings: - type: "array" + type: 'array' items: - $ref: "#/definitions/LDAPSearchSettings" + $ref: '#/definitions/LDAPSearchSettings' GroupSearchSettings: - type: "array" + type: 'array' items: - $ref: "#/definitions/LDAPGroupSearchSettings" + $ref: '#/definitions/LDAPGroupSearchSettings' AutoCreateUsers: - type: "boolean" + type: 'boolean' example: true - description: "Automatically provision users and assign them to matching LDAP group names" + description: 'Automatically provision users and assign them to matching LDAP group names' Settings: - type: "object" + type: 'object' properties: TemplatesURL: - type: "string" - example: "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + type: 'string' + example: 'https://raw.githubusercontent.com/portainer/templates/master/templates.json' description: "URL to the templates that will be displayed in the UI when navigating\ \ to App Templates" LogoURL: - type: "string" - example: "https://mycompany.mydomain.tld/logo.png" + type: 'string' + example: 'https://mycompany.mydomain.tld/logo.png' description: "URL to a logo that will be displayed on the login page as well\ \ as on top of the sidebar. Will use default Portainer logo when value is\ \ empty string" BlackListedLabels: - type: "array" + type: 'array' description: "A list of label name & value that will be used to hide containers\ \ when querying containers" items: - $ref: "#/definitions/Settings_BlackListedLabels" + $ref: '#/definitions/Settings_BlackListedLabels' DisplayExternalContributors: - type: "boolean" + type: 'boolean' example: false description: "Whether to display or not external templates contributions as\ \ sub-menus in the UI." AuthenticationMethod: - type: "integer" + type: 'integer' example: 1 - description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." + description: 'Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP.' LDAPSettings: - $ref: "#/definitions/LDAPSettings" + $ref: '#/definitions/LDAPSettings' AllowBindMountsForRegularUsers: - type: "boolean" + type: 'boolean' example: false - description: "Whether non-administrator should be able to use bind mounts when creating containers" + description: 'Whether non-administrator should be able to use bind mounts when creating containers' AllowPrivilegedModeForRegularUsers: - type: "boolean" + type: 'boolean' example: true - description: "Whether non-administrator should be able to use privileged mode when creating containers" + description: 'Whether non-administrator should be able to use privileged mode when creating containers' Settings_BlackListedLabels: properties: name: - type: "string" - example: "com.foo" + type: 'string' + example: 'com.foo' value: - type: "string" - example: "bar" + type: 'string' + example: 'bar' Pair: properties: name: - type: "string" - example: "name" + type: 'string' + example: 'name' value: - type: "string" - example: "value" + type: 'string' + example: 'value' Registry: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Registry identifier" + description: 'Registry identifier' Name: - type: "string" - example: "my-registry" - description: "Registry name" + type: 'string' + example: 'my-registry' + description: 'Registry name' URL: - type: "string" - example: "registry.mydomain.tld:2375" - description: "URL or IP address of the Docker registry" + type: 'string' + example: 'registry.mydomain.tld:2375' + description: 'URL or IP address of the Docker registry' Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against this registry enabled" + description: 'Is authentication against this registry enabled' Username: - type: "string" - example: "registry_user" - description: "Username used to authenticate against this registry" + type: 'string' + example: 'registry_user' + description: 'Username used to authenticate against this registry' Password: - type: "string" - example: "registry_password" - description: "Password used to authenticate against this registry" + type: 'string' + example: 'registry_password' + description: 'Password used to authenticate against this registry' AuthorizedUsers: - type: "array" - description: "List of user identifiers authorized to use this registry" + type: 'array' + description: 'List of user identifiers authorized to use this registry' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' AuthorizedTeams: - type: "array" - description: "List of team identifiers authorized to use this registry" + type: 'array' + description: 'List of team identifiers authorized to use this registry' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' RegistrySubset: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Registry identifier" + description: 'Registry identifier' Name: - type: "string" - example: "my-registry" - description: "Registry name" + type: 'string' + example: 'my-registry' + description: 'Registry name' URL: - type: "string" - example: "registry.mydomain.tld:2375" - description: "URL or IP address of the Docker registry" + type: 'string' + example: 'registry.mydomain.tld:2375' + description: 'URL or IP address of the Docker registry' Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against this registry enabled" + description: 'Is authentication against this registry enabled' Username: - type: "string" - example: "registry_user" - description: "Username used to authenticate against this registry" + type: 'string' + example: 'registry_user' + description: 'Username used to authenticate against this registry' AuthorizedUsers: - type: "array" - description: "List of user identifiers authorized to use this registry" + type: 'array' + description: 'List of user identifiers authorized to use this registry' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' AuthorizedTeams: - type: "array" - description: "List of team identifiers authorized to use this registry" + type: 'array' + description: 'List of team identifiers authorized to use this registry' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' EndpointGroup: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint group identifier" + description: 'Endpoint group identifier' Name: - type: "string" - example: "my-endpoint-group" - description: "Endpoint group name" + type: 'string' + example: 'my-endpoint-group' + description: 'Endpoint group name' Description: - type: "string" - example: "Description associated to the endpoint group" - description: "Endpoint group description" + type: 'string' + example: 'Description associated to the endpoint group' + description: 'Endpoint group description' AuthorizedUsers: - type: "array" - description: "List of user identifiers authorized to connect to this endpoint group. Will be inherited by endpoints that are part of the group" + type: 'array' + description: 'List of user identifiers authorized to connect to this endpoint group. Will be inherited by endpoints that are part of the group' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' AuthorizedTeams: - type: "array" - description: "List of team identifiers authorized to connect to this endpoint. Will be inherited by endpoints that are part of the group" + type: 'array' + description: 'List of team identifiers authorized to connect to this endpoint. Will be inherited by endpoints that are part of the group' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' Labels: - type: "array" + type: 'array' items: - $ref: "#/definitions/Pair" + $ref: '#/definitions/Pair' Endpoint: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint identifier" + description: 'Endpoint identifier' Name: - type: "string" - example: "my-endpoint" - description: "Endpoint name" + type: 'string' + example: 'my-endpoint' + description: 'Endpoint name' Type: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." + description: 'Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment.' URL: - type: "string" - example: "docker.mydomain.tld:2375" - description: "URL or IP address of the Docker host associated to this endpoint" + type: 'string' + example: 'docker.mydomain.tld:2375' + description: 'URL or IP address of the Docker host associated to this endpoint' PublicURL: - type: "string" - example: "docker.mydomain.tld:2375" - description: "URL or IP address where exposed containers will be reachable" + type: 'string' + example: 'docker.mydomain.tld:2375' + description: 'URL or IP address where exposed containers will be reachable' GroupID: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint group identifier" + description: 'Endpoint group identifier' AuthorizedUsers: - type: "array" - description: "List of user identifiers authorized to connect to this endpoint" + type: 'array' + description: 'List of user identifiers authorized to connect to this endpoint' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' AuthorizedTeams: - type: "array" - description: "List of team identifiers authorized to connect to this endpoint" + type: 'array' + description: 'List of team identifiers authorized to connect to this endpoint' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' TLSConfig: - $ref: "#/definitions/TLSConfiguration" + $ref: '#/definitions/TLSConfiguration' AzureCredentials: - $ref: "#/definitions/AzureCredentials" + $ref: '#/definitions/AzureCredentials' EndpointSubset: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint identifier" + description: 'Endpoint identifier' Name: - type: "string" - example: "my-endpoint" - description: "Endpoint name" + type: 'string' + example: 'my-endpoint' + description: 'Endpoint name' Type: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." + description: 'Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment.' URL: - type: "string" - example: "docker.mydomain.tld:2375" - description: "URL or IP address of the Docker host associated to this endpoint" + type: 'string' + example: 'docker.mydomain.tld:2375' + description: 'URL or IP address of the Docker host associated to this endpoint' PublicURL: - type: "string" - example: "docker.mydomain.tld:2375" - description: "URL or IP address where exposed containers will be reachable" + type: 'string' + example: 'docker.mydomain.tld:2375' + description: 'URL or IP address where exposed containers will be reachable' GroupID: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint group identifier" + description: 'Endpoint group identifier' AuthorizedUsers: - type: "array" - description: "List of user identifiers authorized to connect to this endpoint" + type: 'array' + description: 'List of user identifiers authorized to connect to this endpoint' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' AuthorizedTeams: - type: "array" - description: "List of team identifiers authorized to connect to this endpoint" + type: 'array' + description: 'List of team identifiers authorized to connect to this endpoint' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' TLSConfig: - $ref: "#/definitions/TLSConfiguration" + $ref: '#/definitions/TLSConfiguration' GenericError: - type: "object" + type: 'object' properties: err: - type: "string" - example: "Something bad happened" - description: "Error message" + type: 'string' + example: 'Something bad happened' + description: 'Error message' AuthenticateUserRequest: - type: "object" + type: 'object' required: - - "Password" - - "Username" + - 'Password' + - 'Username' properties: Username: - type: "string" - example: "admin" - description: "Username" + type: 'string' + example: 'admin' + description: 'Username' Password: - type: "string" - example: "mypassword" - description: "Password" + type: 'string' + example: 'mypassword' + description: 'Password' AuthenticateUserResponse: - type: "object" + type: 'object' properties: jwt: - type: "string" - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE" - description: "JWT token used to authenticate against the API" + type: 'string' + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE' + description: 'JWT token used to authenticate against the API' DockerHubSubset: - type: "object" + type: 'object' properties: Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against DockerHub enabled" + description: 'Is authentication against DockerHub enabled' Username: - type: "string" - example: "hub_user" - description: "Username used to authenticate against the DockerHub" + type: 'string' + example: 'hub_user' + description: 'Username used to authenticate against the DockerHub' DockerHub: - type: "object" + type: 'object' properties: Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against DockerHub enabled" + description: 'Is authentication against DockerHub enabled' Username: - type: "string" - example: "hub_user" - description: "Username used to authenticate against the DockerHub" + type: 'string' + example: 'hub_user' + description: 'Username used to authenticate against the DockerHub' Password: - type: "string" - example: "hub_password" - description: "Password used to authenticate against the DockerHub" + type: 'string' + example: 'hub_password' + description: 'Password used to authenticate against the DockerHub' ResourceControl: - type: "object" + type: 'object' properties: ResourceID: - type: "string" - example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + type: 'string' + example: '617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08' description: "Docker resource identifier on which access control will be applied.\ \ In the case of a resource control applied to a stack, use the stack name as identifier" Type: - type: "string" - example: "container" + type: 'string' + example: 'container' description: "Type of Docker resource. Valid values are: container, volume\ \ service, secret, config or stack" Public: - type: "boolean" + type: 'boolean' example: true - description: "Permit access to the associated resource to any user" + description: 'Permit access to the associated resource to any user' Users: - type: "array" - description: "List of user identifiers with access to the associated resource" + type: 'array' + description: 'List of user identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' Teams: - type: "array" - description: "List of team identifiers with access to the associated resource" + type: 'array' + description: 'List of team identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' SubResourceIDs: - type: "array" - description: "List of Docker resources that will inherit this access control" + type: 'array' + description: 'List of Docker resources that will inherit this access control' items: - type: "string" - example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" - description: "Docker resource identifier" + type: 'string' + example: '617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08' + description: 'Docker resource identifier' DockerHubUpdateRequest: - type: "object" + type: 'object' required: - - "Authentication" - - "Password" - - "Username" + - 'Authentication' + - 'Password' + - 'Username' properties: Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Enable authentication against DockerHub" + description: 'Enable authentication against DockerHub' Username: - type: "string" - example: "hub_user" - description: "Username used to authenticate against the DockerHub" + type: 'string' + example: 'hub_user' + description: 'Username used to authenticate against the DockerHub' Password: - type: "string" - example: "hub_password" - description: "Password used to authenticate against the DockerHub" + type: 'string' + example: 'hub_password' + description: 'Password used to authenticate against the DockerHub' EndpointListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/EndpointSubset" + $ref: '#/definitions/EndpointSubset' EndpointGroupListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/EndpointGroup" + $ref: '#/definitions/EndpointGroup' EndpointUpdateRequest: - type: "object" + type: 'object' properties: Name: - type: "string" - example: "my-endpoint" - description: "Name that will be used to identify this endpoint" + type: 'string' + example: 'my-endpoint' + description: 'Name that will be used to identify this endpoint' URL: - type: "string" - example: "docker.mydomain.tld:2375" - description: "URL or IP address of a Docker host" + type: 'string' + example: 'docker.mydomain.tld:2375' + description: 'URL or IP address of a Docker host' PublicURL: - type: "string" - example: "docker.mydomain.tld:2375" + type: 'string' + example: 'docker.mydomain.tld:2375' description: "URL or IP address where exposed containers will be reachable.\ \ Defaults to URL if not specified" GroupID: - type: "integer" - example: "1" - description: "Group identifier" + type: 'integer' + example: '1' + description: 'Group identifier' TLS: - type: "boolean" + type: 'boolean' example: true - description: "Require TLS to connect against this endpoint" + description: 'Require TLS to connect against this endpoint' TLSSkipVerify: - type: "boolean" + type: 'boolean' example: false - description: "Skip server verification when using TLS" + description: 'Skip server verification when using TLS' TLSSkipClientVerify: - type: "boolean" + type: 'boolean' example: false - description: "Skip client verification when using TLS" + description: 'Skip client verification when using TLS' ApplicationID: - type: "string" - example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" - description: "Azure application ID" + type: 'string' + example: 'eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4' + description: 'Azure application ID' TenantID: - type: "string" - example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" - description: "Azure tenant ID" + type: 'string' + example: '34ddc78d-4fel-2358-8cc1-df84c8o839f5' + description: 'Azure tenant ID' AuthenticationKey: - type: "string" - example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" - description: "Azure authentication key" + type: 'string' + example: 'cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=' + description: 'Azure authentication key' UserAccessPolicies: - $ref: "#/definitions/UserAccessPolicies" + $ref: '#/definitions/UserAccessPolicies' TeamAccessPolicies: - $ref: "#/definitions/TeamAccessPolicies" + $ref: '#/definitions/TeamAccessPolicies' RegistryCreateRequest: - type: "object" + type: 'object' required: - - "Authentication" - - "Name" - - "Password" - - "Type" - - "URL" - - "Username" + - 'Authentication' + - 'Name' + - 'Password' + - 'Type' + - 'URL' + - 'Username' properties: Name: - type: "string" - example: "my-registry" - description: "Name that will be used to identify this registry" + type: 'string' + example: 'my-registry' + description: 'Name that will be used to identify this registry' Type: - type: "integer" + type: 'integer' example: 1 - description: "Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)" + description: 'Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)' URL: - type: "string" - example: "registry.mydomain.tld:2375" - description: "URL or IP address of the Docker registry" + type: 'string' + example: 'registry.mydomain.tld:2375' + description: 'URL or IP address of the Docker registry' Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against this registry enabled" + description: 'Is authentication against this registry enabled' Username: - type: "string" - example: "registry_user" - description: "Username used to authenticate against this registry" + type: 'string' + example: 'registry_user' + description: 'Username used to authenticate against this registry' Password: - type: "string" - example: "registry_password" - description: "Password used to authenticate against this registry" + type: 'string' + example: 'registry_password' + description: 'Password used to authenticate against this registry' RegistryListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/RegistrySubset" + $ref: '#/definitions/RegistrySubset' RegistryUpdateRequest: - type: "object" + type: 'object' required: - - "Name" - - "URL" + - 'Name' + - 'URL' properties: Name: - type: "string" - example: "my-registry" - description: "Name that will be used to identify this registry" + type: 'string' + example: 'my-registry' + description: 'Name that will be used to identify this registry' URL: - type: "string" - example: "registry.mydomain.tld:2375" - description: "URL or IP address of the Docker registry" + type: 'string' + example: 'registry.mydomain.tld:2375' + description: 'URL or IP address of the Docker registry' Authentication: - type: "boolean" + type: 'boolean' example: true - description: "Is authentication against this registry enabled" + description: 'Is authentication against this registry enabled' Username: - type: "string" - example: "registry_user" - description: "Username used to authenticate against this registry" + type: 'string' + example: 'registry_user' + description: 'Username used to authenticate against this registry' Password: - type: "string" - example: "registry_password" - description: "Password used to authenticate against this registry" + type: 'string' + example: 'registry_password' + description: 'Password used to authenticate against this registry' UserAccessPolicies: - $ref: "#/definitions/UserAccessPolicies" + $ref: '#/definitions/UserAccessPolicies' TeamAccessPolicies: - $ref: "#/definitions/TeamAccessPolicies" + $ref: '#/definitions/TeamAccessPolicies' ResourceControlCreateRequest: - type: "object" + type: 'object' required: - - "ResourceID" - - "Type" + - 'ResourceID' + - 'Type' properties: ResourceID: - type: "string" - example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + type: 'string' + example: '617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08' description: "Docker resource identifier on which access control will be applied.\ \ In the case of a resource control applied to a stack, use the stack name as identifier" Type: - type: "string" - example: "container" + type: 'string' + example: 'container' description: "Type of Docker resource. Valid values are: container, volume\ \ service, secret, config or stack" Public: - type: "boolean" + type: 'boolean' example: true - description: "Permit access to the associated resource to any user" + description: 'Permit access to the associated resource to any user' Users: - type: "array" - description: "List of user identifiers with access to the associated resource" + type: 'array' + description: 'List of user identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' Teams: - type: "array" - description: "List of team identifiers with access to the associated resource" + type: 'array' + description: 'List of team identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' SubResourceIDs: - type: "array" - description: "List of Docker resources that will inherit this access control" + type: 'array' + description: 'List of Docker resources that will inherit this access control' items: - type: "string" - example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" - description: "Docker resource identifier" + type: 'string' + example: '617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08' + description: 'Docker resource identifier' ResourceControlUpdateRequest: - type: "object" + type: 'object' properties: Public: - type: "boolean" + type: 'boolean' example: false - description: "Permit access to the associated resource to any user" + description: 'Permit access to the associated resource to any user' Users: - type: "array" - description: "List of user identifiers with access to the associated resource" + type: 'array' + description: 'List of user identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' Teams: - type: "array" - description: "List of team identifiers with access to the associated resource" + type: 'array' + description: 'List of team identifiers with access to the associated resource' items: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' SettingsUpdateRequest: - type: "object" + type: 'object' required: - - "TemplatesURL" - - "AuthenticationMethod" + - 'TemplatesURL' + - 'AuthenticationMethod' properties: TemplatesURL: - type: "string" - example: "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + type: 'string' + example: 'https://raw.githubusercontent.com/portainer/templates/master/templates.json' description: "URL to the templates that will be displayed in the UI when navigating\ \ to App Templates" LogoURL: - type: "string" - example: "https://mycompany.mydomain.tld/logo.png" + type: 'string' + example: 'https://mycompany.mydomain.tld/logo.png' description: "URL to a logo that will be displayed on the login page as well\ \ as on top of the sidebar. Will use default Portainer logo when value is\ \ empty string" BlackListedLabels: - type: "array" + type: 'array' description: "A list of label name & value that will be used to hide containers\ \ when querying containers" items: - $ref: "#/definitions/Settings_BlackListedLabels" + $ref: '#/definitions/Settings_BlackListedLabels' DisplayExternalContributors: - type: "boolean" + type: 'boolean' example: false description: "Whether to display or not external templates contributions as\ \ sub-menus in the UI." AuthenticationMethod: - type: "integer" + type: 'integer' example: 1 - description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." + description: 'Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP.' LDAPSettings: - $ref: "#/definitions/LDAPSettings" + $ref: '#/definitions/LDAPSettings' AllowBindMountsForRegularUsers: - type: "boolean" + type: 'boolean' example: true - description: "Whether non-administrator users should be able to use bind mounts when creating containers" + description: 'Whether non-administrator users should be able to use bind mounts when creating containers' AllowPrivilegedModeForRegularUsers: - type: "boolean" + type: 'boolean' example: true - description: "Whether non-administrator users should be able to use privileged mode when creating containers" + description: 'Whether non-administrator users should be able to use privileged mode when creating containers' EdgeAgentCheckinInterval: - type: "integer" - example: "30" - description: "Polling interval for Edge agent (in seconds)" + type: 'integer' + example: '30' + description: 'Polling interval for Edge agent (in seconds)' EndpointGroupCreateRequest: - type: "object" + type: 'object' required: - - "Name" + - 'Name' properties: Name: - type: "string" - example: "my-endpoint-group" - description: "Endpoint group name" + type: 'string' + example: 'my-endpoint-group' + description: 'Endpoint group name' Description: - type: "string" - example: "Endpoint group description" - description: "Endpoint group description" + type: 'string' + example: 'Endpoint group description' + description: 'Endpoint group description' Labels: - type: "array" + type: 'array' items: - $ref: "#/definitions/Pair" + $ref: '#/definitions/Pair' AssociatedEndpoints: - type: "array" - description: "List of endpoint identifiers that will be part of this group" + type: 'array' + description: 'List of endpoint identifiers that will be part of this group' items: - type: "integer" + type: 'integer' example: 1 - description: "Endpoint identifier" + description: 'Endpoint identifier' EndpointGroupUpdateRequest: - type: "object" + type: 'object' properties: Name: - type: "string" - example: "my-endpoint-group" - description: "Endpoint group name" + type: 'string' + example: 'my-endpoint-group' + description: 'Endpoint group name' Description: - type: "string" - example: "Endpoint group description" - description: "Endpoint group description" + type: 'string' + example: 'Endpoint group description' + description: 'Endpoint group description' Tags: - type: "array" - description: "List of tags associated to the endpoint group" + type: 'array' + description: 'List of tags associated to the endpoint group' items: - type: "string" - example: "zone/east-coast" - description: "Tag" + type: 'string' + example: 'zone/east-coast' + description: 'Tag' UserAccessPolicies: - $ref: "#/definitions/UserAccessPolicies" + $ref: '#/definitions/UserAccessPolicies' TeamAccessPolicies: - $ref: "#/definitions/TeamAccessPolicies" + $ref: '#/definitions/TeamAccessPolicies' UserCreateRequest: - type: "object" + type: 'object' required: - - "Password" - - "Role" - - "Username" + - 'Password' + - 'Role' + - 'Username' properties: Username: - type: "string" - example: "bob" - description: "Username" + type: 'string' + example: 'bob' + description: 'Username' Password: - type: "string" - example: "cg9Wgky3" - description: "Password" + type: 'string' + example: 'cg9Wgky3' + description: 'Password' Role: - type: "integer" + type: 'integer' example: 1 - description: "User role (1 for administrator account and 2 for regular account)" + description: 'User role (1 for administrator account and 2 for regular account)' UserListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/UserSubset" + $ref: '#/definitions/UserSubset' UserUpdateRequest: - type: "object" + type: 'object' properties: Password: - type: "string" - example: "cg9Wgky3" - description: "Password" + type: 'string' + example: 'cg9Wgky3' + description: 'Password' Role: - type: "integer" + type: 'integer' example: 1 - description: "User role (1 for administrator account and 2 for regular account)" + description: 'User role (1 for administrator account and 2 for regular account)' UserMembershipsResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/TeamMembership" + $ref: '#/definitions/TeamMembership' UserPasswordCheckRequest: - type: "object" + type: 'object' required: - - "Password" + - 'Password' properties: Password: - type: "string" - example: "cg9Wgky3" - description: "Password" + type: 'string' + example: 'cg9Wgky3' + description: 'Password' UserPasswordCheckResponse: - type: "object" + type: 'object' properties: valid: - type: "boolean" + type: 'boolean' example: true - description: "Is the password valid" + description: 'Is the password valid' TagListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Tag" + $ref: '#/definitions/Tag' TagCreateRequest: - type: "object" + type: 'object' required: - - "Name" + - 'Name' properties: Name: - type: "string" - example: "org/acme" - description: "Name" + type: 'string' + example: 'org/acme' + description: 'Name' TeamCreateRequest: - type: "object" + type: 'object' required: - - "Name" + - 'Name' properties: Name: - type: "string" - example: "developers" - description: "Name" + type: 'string' + example: 'developers' + description: 'Name' TeamListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Team" + $ref: '#/definitions/Team' TeamUpdateRequest: - type: "object" + type: 'object' required: - - "Name" + - 'Name' properties: Name: - type: "string" - example: "developers" - description: "Name" + type: 'string' + example: 'developers' + description: 'Name' TeamMembershipsResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/TeamMembership" + $ref: '#/definitions/TeamMembership' TeamMembershipCreateRequest: - type: "object" + type: 'object' required: - - "UserID" - - "TeamID" - - "Role" + - 'UserID' + - 'TeamID' + - 'Role' properties: UserID: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' TeamID: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' Role: - type: "integer" + type: 'integer' example: 1 - description: "Role for the user inside the team (1 for leader and 2 for regular member)" + description: 'Role for the user inside the team (1 for leader and 2 for regular member)' TeamMembershipListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/TeamMembership" + $ref: '#/definitions/TeamMembership' TeamMembershipUpdateRequest: - type: "object" + type: 'object' required: - - "UserID" - - "TeamID" - - "Role" + - 'UserID' + - 'TeamID' + - 'Role' properties: UserID: - type: "integer" + type: 'integer' example: 1 - description: "User identifier" + description: 'User identifier' TeamID: - type: "integer" + type: 'integer' example: 1 - description: "Team identifier" + description: 'Team identifier' Role: - type: "integer" + type: 'integer' example: 1 - description: "Role for the user inside the team (1 for leader and 2 for regular member)" + description: 'Role for the user inside the team (1 for leader and 2 for regular member)' SettingsLDAPCheckRequest: - type: "object" + type: 'object' properties: LDAPSettings: - $ref: "#/definitions/LDAPSettings" + $ref: '#/definitions/LDAPSettings' UserAdminInitRequest: - type: "object" + type: 'object' properties: Username: - type: "string" - example: "admin" - description: "Username for the admin user" + type: 'string' + example: 'admin' + description: 'Username for the admin user' Password: - type: "string" - example: "admin-password" - description: "Password for the admin user" + type: 'string' + example: 'admin-password' + description: 'Password for the admin user' TemplateListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Template" + $ref: '#/definitions/Template' TemplateCreateRequest: - type: "object" + type: 'object' required: - - "type" - - "title" - - "description" + - 'type' + - 'title' + - 'description' properties: type: - type: "integer" + type: 'integer' example: 1 - description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" + description: 'Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)' title: - type: "string" - example: "Nginx" - description: "Title of the template" + type: 'string' + example: 'Nginx' + description: 'Title of the template' description: - type: "string" - example: "High performance web server" - description: "Description of the template" + type: 'string' + example: 'High performance web server' + description: 'Description of the template' administrator_only: - type: "boolean" + type: 'boolean' example: true - description: "Whether the template should be available to administrators only" + description: 'Whether the template should be available to administrators only' image: - type: "string" - example: "nginx:latest" - description: "Image associated to a container template. Mandatory for a container template" + type: 'string' + example: 'nginx:latest' + description: 'Image associated to a container template. Mandatory for a container template' repository: - $ref: "#/definitions/TemplateRepository" + $ref: '#/definitions/TemplateRepository' name: - type: "string" - example: "mystackname" - description: "Default name for the stack/container to be used on deployment" + type: 'string' + example: 'mystackname' + description: 'Default name for the stack/container to be used on deployment' logo: - type: "string" - example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + type: 'string' + example: 'https://cloudinovasi.id/assets/img/logos/nginx.png' description: "URL of the template's logo" env: - type: "array" - description: "A list of environment variables used during the template deployment" + type: 'array' + description: 'A list of environment variables used during the template deployment' items: - $ref: "#/definitions/TemplateEnv" + $ref: '#/definitions/TemplateEnv' note: - type: "string" - example: "This is my custom template" - description: "A note that will be displayed in the UI. Supports HTML content" + type: 'string' + example: 'This is my custom template' + description: 'A note that will be displayed in the UI. Supports HTML content' platform: - type: "string" - example: "linux" + type: 'string' + example: 'linux' description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" categories: - type: "array" - description: "A list of categories associated to the template" + type: 'array' + description: 'A list of categories associated to the template' items: - type: "string" - example: "database" + type: 'string' + example: 'database' registry: - type: "string" - example: "quay.io" - description: "The URL of a registry associated to the image for a container template" + type: 'string' + example: 'quay.io' + description: 'The URL of a registry associated to the image for a container template' command: - type: "string" - example: "ls -lah" - description: "The command that will be executed in a container template" + type: 'string' + example: 'ls -lah' + description: 'The command that will be executed in a container template' network: - type: "string" - example: "mynet" - description: "Name of a network that will be used on container deployment if it exists inside the environment" + type: 'string' + example: 'mynet' + description: 'Name of a network that will be used on container deployment if it exists inside the environment' volumes: - type: "array" - description: "A list of volumes used during the container template deployment" + type: 'array' + description: 'A list of volumes used during the container template deployment' items: - $ref: "#/definitions/TemplateVolume" + $ref: '#/definitions/TemplateVolume' ports: - type: "array" - description: "A list of ports exposed by the container" + type: 'array' + description: 'A list of ports exposed by the container' items: - type: "string" - example: "8080:80/tcp" + type: 'string' + example: '8080:80/tcp' labels: - type: "array" - description: "Container labels" + type: 'array' + description: 'Container labels' items: $ref: '#/definitions/Pair' privileged: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in privileged mode" + description: 'Whether the container should be started in privileged mode' interactive: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + description: 'Whether the container should be started in interactive mode (-i -t equivalent on the CLI)' restart_policy: - type: "string" - example: "on-failure" - description: "Container restart policy" + type: 'string' + example: 'on-failure' + description: 'Container restart policy' hostname: - type: "string" - example: "mycontainer" - description: "Container hostname" + type: 'string' + example: 'mycontainer' + description: 'Container hostname' TemplateUpdateRequest: - type: "object" + type: 'object' properties: type: - type: "integer" + type: 'integer' example: 1 - description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" + description: 'Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)' title: - type: "string" - example: "Nginx" - description: "Title of the template" + type: 'string' + example: 'Nginx' + description: 'Title of the template' description: - type: "string" - example: "High performance web server" - description: "Description of the template" + type: 'string' + example: 'High performance web server' + description: 'Description of the template' administrator_only: - type: "boolean" + type: 'boolean' example: true - description: "Whether the template should be available to administrators only" + description: 'Whether the template should be available to administrators only' image: - type: "string" - example: "nginx:latest" - description: "Image associated to a container template. Mandatory for a container template" + type: 'string' + example: 'nginx:latest' + description: 'Image associated to a container template. Mandatory for a container template' repository: - $ref: "#/definitions/TemplateRepository" + $ref: '#/definitions/TemplateRepository' name: - type: "string" - example: "mystackname" - description: "Default name for the stack/container to be used on deployment" + type: 'string' + example: 'mystackname' + description: 'Default name for the stack/container to be used on deployment' logo: - type: "string" - example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + type: 'string' + example: 'https://cloudinovasi.id/assets/img/logos/nginx.png' description: "URL of the template's logo" env: - type: "array" - description: "A list of environment variables used during the template deployment" + type: 'array' + description: 'A list of environment variables used during the template deployment' items: - $ref: "#/definitions/TemplateEnv" + $ref: '#/definitions/TemplateEnv' note: - type: "string" - example: "This is my custom template" - description: "A note that will be displayed in the UI. Supports HTML content" + type: 'string' + example: 'This is my custom template' + description: 'A note that will be displayed in the UI. Supports HTML content' platform: - type: "string" - example: "linux" + type: 'string' + example: 'linux' description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" categories: - type: "array" - description: "A list of categories associated to the template" + type: 'array' + description: 'A list of categories associated to the template' items: - type: "string" - example: "database" + type: 'string' + example: 'database' registry: - type: "string" - example: "quay.io" - description: "The URL of a registry associated to the image for a container template" + type: 'string' + example: 'quay.io' + description: 'The URL of a registry associated to the image for a container template' command: - type: "string" - example: "ls -lah" - description: "The command that will be executed in a container template" + type: 'string' + example: 'ls -lah' + description: 'The command that will be executed in a container template' network: - type: "string" - example: "mynet" - description: "Name of a network that will be used on container deployment if it exists inside the environment" + type: 'string' + example: 'mynet' + description: 'Name of a network that will be used on container deployment if it exists inside the environment' volumes: - type: "array" - description: "A list of volumes used during the container template deployment" + type: 'array' + description: 'A list of volumes used during the container template deployment' items: - $ref: "#/definitions/TemplateVolume" + $ref: '#/definitions/TemplateVolume' ports: - type: "array" - description: "A list of ports exposed by the container" + type: 'array' + description: 'A list of ports exposed by the container' items: - type: "string" - example: "8080:80/tcp" + type: 'string' + example: '8080:80/tcp' labels: - type: "array" - description: "Container labels" + type: 'array' + description: 'Container labels' items: $ref: '#/definitions/Pair' privileged: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in privileged mode" + description: 'Whether the container should be started in privileged mode' interactive: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + description: 'Whether the container should be started in interactive mode (-i -t equivalent on the CLI)' restart_policy: - type: "string" - example: "on-failure" - description: "Container restart policy" + type: 'string' + example: 'on-failure' + description: 'Container restart policy' hostname: - type: "string" - example: "mycontainer" - description: "Container hostname" + type: 'string' + example: 'mycontainer' + description: 'Container hostname' Template: - type: "object" + type: 'object' properties: id: - type: "integer" + type: 'integer' example: 1 - description: "Template identifier" + description: 'Template identifier' type: - type: "integer" + type: 'integer' example: 1 - description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)" + description: 'Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)' title: - type: "string" - example: "Nginx" - description: "Title of the template" + type: 'string' + example: 'Nginx' + description: 'Title of the template' description: - type: "string" - example: "High performance web server" - description: "Description of the template" + type: 'string' + example: 'High performance web server' + description: 'Description of the template' administrator_only: - type: "boolean" + type: 'boolean' example: true - description: "Whether the template should be available to administrators only" + description: 'Whether the template should be available to administrators only' image: - type: "string" - example: "nginx:latest" - description: "Image associated to a container template. Mandatory for a container template" + type: 'string' + example: 'nginx:latest' + description: 'Image associated to a container template. Mandatory for a container template' repository: - $ref: "#/definitions/TemplateRepository" + $ref: '#/definitions/TemplateRepository' name: - type: "string" - example: "mystackname" - description: "Default name for the stack/container to be used on deployment" + type: 'string' + example: 'mystackname' + description: 'Default name for the stack/container to be used on deployment' logo: - type: "string" - example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + type: 'string' + example: 'https://cloudinovasi.id/assets/img/logos/nginx.png' description: "URL of the template's logo" env: - type: "array" - description: "A list of environment variables used during the template deployment" + type: 'array' + description: 'A list of environment variables used during the template deployment' items: - $ref: "#/definitions/TemplateEnv" + $ref: '#/definitions/TemplateEnv' note: - type: "string" - example: "This is my custom template" - description: "A note that will be displayed in the UI. Supports HTML content" + type: 'string' + example: 'This is my custom template' + description: 'A note that will be displayed in the UI. Supports HTML content' platform: - type: "string" - example: "linux" + type: 'string' + example: 'linux' description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform" categories: - type: "array" - description: "A list of categories associated to the template" + type: 'array' + description: 'A list of categories associated to the template' items: - type: "string" - example: "database" + type: 'string' + example: 'database' registry: - type: "string" - example: "quay.io" - description: "The URL of a registry associated to the image for a container template" + type: 'string' + example: 'quay.io' + description: 'The URL of a registry associated to the image for a container template' command: - type: "string" - example: "ls -lah" - description: "The command that will be executed in a container template" + type: 'string' + example: 'ls -lah' + description: 'The command that will be executed in a container template' network: - type: "string" - example: "mynet" - description: "Name of a network that will be used on container deployment if it exists inside the environment" + type: 'string' + example: 'mynet' + description: 'Name of a network that will be used on container deployment if it exists inside the environment' volumes: - type: "array" - description: "A list of volumes used during the container template deployment" + type: 'array' + description: 'A list of volumes used during the container template deployment' items: - $ref: "#/definitions/TemplateVolume" + $ref: '#/definitions/TemplateVolume' ports: - type: "array" - description: "A list of ports exposed by the container" + type: 'array' + description: 'A list of ports exposed by the container' items: - type: "string" - example: "8080:80/tcp" + type: 'string' + example: '8080:80/tcp' labels: - type: "array" - description: "Container labels" + type: 'array' + description: 'Container labels' items: $ref: '#/definitions/Pair' privileged: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in privileged mode" + description: 'Whether the container should be started in privileged mode' interactive: - type: "boolean" + type: 'boolean' example: true - description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)" + description: 'Whether the container should be started in interactive mode (-i -t equivalent on the CLI)' restart_policy: - type: "string" - example: "on-failure" - description: "Container restart policy" + type: 'string' + example: 'on-failure' + description: 'Container restart policy' hostname: - type: "string" - example: "mycontainer" - description: "Container hostname" + type: 'string' + example: 'mycontainer' + description: 'Container hostname' TemplateVolume: - type: "object" + type: 'object' properties: container: - type: "string" - example: "/data" - description: "Path inside the container" + type: 'string' + example: '/data' + description: 'Path inside the container' bind: - type: "string" - example: "/tmp" - description: "Path on the host" + type: 'string' + example: '/tmp' + description: 'Path on the host' readonly: - type: "boolean" + type: 'boolean' example: true - description: "Whether the volume used should be readonly" + description: 'Whether the volume used should be readonly' TemplateEnv: - type: "object" + type: 'object' properties: name: - type: "string" - example: "MYSQL_ROOT_PASSWORD" - description: "name of the environment variable" + type: 'string' + example: 'MYSQL_ROOT_PASSWORD' + description: 'name of the environment variable' label: - type: "string" - example: "Root password" - description: "Text for the label that will be generated in the UI" + type: 'string' + example: 'Root password' + description: 'Text for the label that will be generated in the UI' description: - type: "string" - example: "MySQL root account password" - description: "Content of the tooltip that will be generated in the UI" + type: 'string' + example: 'MySQL root account password' + description: 'Content of the tooltip that will be generated in the UI' default: - type: "string" - example: "default_value" - description: "Default value that will be set for the variable" + type: 'string' + example: 'default_value' + description: 'Default value that will be set for the variable' preset: - type: "boolean" + type: 'boolean' example: true - description: "If set to true, will not generate any input for this variable in the UI" + description: 'If set to true, will not generate any input for this variable in the UI' select: - type: "array" - description: "A list of name/value that will be used to generate a dropdown in the UI" + type: 'array' + description: 'A list of name/value that will be used to generate a dropdown in the UI' items: $ref: '#/definitions/TemplateEnvSelect' TemplateEnvSelect: - type: "object" + type: 'object' properties: text: - type: "string" - example: "text value" - description: "Some text that will displayed as a choice" + type: 'string' + example: 'text value' + description: 'Some text that will displayed as a choice' value: - type: "string" - example: "value" - description: "A value that will be associated to the choice" + type: 'string' + example: 'value' + description: 'A value that will be associated to the choice' default: - type: "boolean" + type: 'boolean' example: true - description: "Will set this choice as the default choice" + description: 'Will set this choice as the default choice' TemplateRepository: - type: "object" + type: 'object' required: - - "URL" + - 'URL' properties: URL: - type: "string" - example: "https://github.com/portainer/portainer-compose" - description: "URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template" + type: 'string' + example: 'https://github.com/portainer/portainer-compose' + description: 'URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template' stackfile: - type: "string" - example: "./subfolder/docker-compose.yml" - description: "Path to the stack file inside the git repository" + type: 'string' + example: './subfolder/docker-compose.yml' + description: 'Path to the stack file inside the git repository' StackMigrateRequest: - type: "object" + type: 'object' required: - - "EndpointID" + - 'EndpointID' properties: EndpointID: - type: "integer" + type: 'integer' example: 2 - description: "Endpoint identifier of the target endpoint where the stack will be relocated" + description: 'Endpoint identifier of the target endpoint where the stack will be relocated' SwarmID: - type: "string" - example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated" + type: 'string' + example: 'jpofkc0i9uo9wtx1zesuk649w' + description: 'Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated' Name: - type: "string" - example: "new-stack" - description: "If provided will rename the migrated stack" + type: 'string' + example: 'new-stack' + description: 'If provided will rename the migrated stack' EndpointJobRequest: - type: "object" + type: 'object' required: - - "Image" - - "FileContent" + - 'Image' + - 'FileContent' properties: Image: - type: "string" - example: "ubuntu:latest" - description: "Container image which will be used to execute the job" + type: 'string' + example: 'ubuntu:latest' + description: 'Container image which will be used to execute the job' FileContent: - type: "string" - example: "ls -lah /host/tmp" - description: "Content of the job script" + type: 'string' + example: 'ls -lah /host/tmp' + description: 'Content of the job script' StackCreateRequest: - type: "object" + type: 'object' required: - - "Name" + - 'Name' properties: Name: - type: "string" - example: "myStack" - description: "Name of the stack" + type: 'string' + example: 'myStack' + description: 'Name of the stack' SwarmID: - type: "string" - example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Swarm cluster identifier. Required when creating a Swarm stack (type 1)." + type: 'string' + example: 'jpofkc0i9uo9wtx1zesuk649w' + description: 'Swarm cluster identifier. Required when creating a Swarm stack (type 1).' StackFileContent: - type: "string" + type: 'string' example: "version: 3\n services:\n web:\n image:nginx" description: "Content of the Stack file. Required when using the 'string' deployment method." RepositoryURL: - type: "string" - example: "https://github.com/openfaas/faas" + type: 'string' + example: 'https://github.com/openfaas/faas' description: "URL of a Git repository hosting the Stack file. Required when using the 'repository' deployment method." RepositoryReferenceName: - type: "string" - example: "refs/heads/master" + type: 'string' + example: 'refs/heads/master' description: "Reference name of a Git repository hosting the Stack file. Used in 'repository' deployment method." ComposeFilePathInRepository: - type: "string" - example: "docker-compose.yml" + type: 'string' + example: 'docker-compose.yml' description: "Path to the Stack file inside the Git repository. Will default to 'docker-compose.yml' if not specified." RepositoryAuthentication: - type: "boolean" + type: 'boolean' example: true - description: "Use basic authentication to clone the Git repository." + description: 'Use basic authentication to clone the Git repository.' RepositoryUsername: - type: "string" - example: "myGitUsername" - description: "Username used in basic authentication. Required when RepositoryAuthentication is true." + type: 'string' + example: 'myGitUsername' + description: 'Username used in basic authentication. Required when RepositoryAuthentication is true.' RepositoryPassword: - type: "string" - example: "myGitPassword" - description: "Password used in basic authentication. Required when RepositoryAuthentication is true." + type: 'string' + example: 'myGitPassword' + description: 'Password used in basic authentication. Required when RepositoryAuthentication is true.' Env: - type: "array" - description: "A list of environment variables used during stack deployment" + type: 'array' + description: 'A list of environment variables used during stack deployment' items: - $ref: "#/definitions/Stack_Env" + $ref: '#/definitions/Stack_Env' Stack_Env: properties: name: - type: "string" - example: "MYSQL_ROOT_PASSWORD" + type: 'string' + example: 'MYSQL_ROOT_PASSWORD' value: - type: "string" - example: "password" + type: 'string' + example: 'password' StackListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Stack" + $ref: '#/definitions/Stack' Stack: - type: "object" + type: 'object' properties: Id: - type: "string" - example: "myStack_jpofkc0i9uo9wtx1zesuk649w" - description: "Stack identifier" + type: 'string' + example: 'myStack_jpofkc0i9uo9wtx1zesuk649w' + description: 'Stack identifier' Name: - type: "string" - example: "myStack" - description: "Stack name" + type: 'string' + example: 'myStack' + description: 'Stack name' Type: - type: "integer" - example: "1" - description: "Stack type. 1 for a Swarm stack, 2 for a Compose stack" + type: 'integer' + example: '1' + description: 'Stack type. 1 for a Swarm stack, 2 for a Compose stack' EndpointID: - type: "integer" - example: "1" - description: "Endpoint identifier. Reference the endpoint that will be used for deployment " + type: 'integer' + example: '1' + description: 'Endpoint identifier. Reference the endpoint that will be used for deployment ' EntryPoint: - type: "string" - example: "docker-compose.yml" - description: "Path to the Stack file" + type: 'string' + example: 'docker-compose.yml' + description: 'Path to the Stack file' SwarmID: - type: "string" - example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Cluster identifier of the Swarm cluster where the stack is deployed" + type: 'string' + example: 'jpofkc0i9uo9wtx1zesuk649w' + description: 'Cluster identifier of the Swarm cluster where the stack is deployed' ProjectPath: - type: "string" - example: "/data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w" - description: "Path on disk to the repository hosting the Stack file" + type: 'string' + example: '/data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w' + description: 'Path on disk to the repository hosting the Stack file' Env: - type: "array" - description: "A list of environment variables used during stack deployment" + type: 'array' + description: 'A list of environment variables used during stack deployment' items: - $ref: "#/definitions/Stack_Env" + $ref: '#/definitions/Stack_Env' StackUpdateRequest: - type: "object" + type: 'object' properties: StackFileContent: - type: "string" + type: 'string' example: "version: 3\n services:\n web:\n image:nginx" - description: "New content of the Stack file." + description: 'New content of the Stack file.' Env: - type: "array" - description: "A list of environment variables used during stack deployment" + type: 'array' + description: 'A list of environment variables used during stack deployment' items: - $ref: "#/definitions/Stack_Env" + $ref: '#/definitions/Stack_Env' Prune: - type: "boolean" + type: 'boolean' example: false - description: "Prune services that are no longer referenced (only available for Swarm stacks)" + description: 'Prune services that are no longer referenced (only available for Swarm stacks)' StackFileInspectResponse: - type: "object" + type: 'object' properties: StackFileContent: - type: "string" + type: 'string' example: "version: 3\n services:\n web:\n image:nginx" - description: "Content of the Stack file." + description: 'Content of the Stack file.' LicenseInformation: - type: "object" + type: 'object' properties: LicenseKey: - type: "string" - description: "License key" - example: "1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ" + type: 'string' + description: 'License key' + example: '1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ' Company: - type: "string" - description: "Company associated to the license" - example: "Portainer.io" + type: 'string' + description: 'Company associated to the license' + example: 'Portainer.io' Expiration: - type: "string" - description: "License expiry date" - example: "2077-07-07" + type: 'string' + description: 'License expiry date' + example: '2077-07-07' Valid: - type: "boolean" - description: "Is the license valid" - example: "true" + type: 'boolean' + description: 'Is the license valid' + example: 'true' Extension: - type: "object" + type: 'object' properties: Id: - type: "integer" + type: 'integer' example: 1 - description: "Extension identifier" + description: 'Extension identifier' Name: - type: "string" - example: "Registry Manager" - description: "Extension name" + type: 'string' + example: 'Registry Manager' + description: 'Extension name' Enabled: - type: "boolean" - example: "true" - description: "Is the extension enabled" + type: 'boolean' + example: 'true' + description: 'Is the extension enabled' ShortDescription: - type: "string" - description: "Short description about the extension" - example: "Enable in-app registry management" + type: 'string' + description: 'Short description about the extension' + example: 'Enable in-app registry management' DescriptionURL: - type: "string" - description: "URL to the file containing the extension description" + type: 'string' + description: 'URL to the file containing the extension description' example: https://portainer-io-assets.sfo2.digitaloceanspaces.com/description_registry_manager.html" Available: - type: "boolean" - description: "Is the extension available for download and activation" - example: "true" + type: 'boolean' + description: 'Is the extension available for download and activation' + example: 'true' Images: - type: "array" - description: "List of screenshot URLs" + type: 'array' + description: 'List of screenshot URLs' items: - type: "string" - example: "https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm01.png" - description: "Screenshot URL" + type: 'string' + example: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm01.png' + description: 'Screenshot URL' Logo: - type: "string" - description: "Icon associated to the extension" - example: "fa-database" + type: 'string' + description: 'Icon associated to the extension' + example: 'fa-database' Price: - type: "string" - description: "Extension price" - example: "US$9.95" + type: 'string' + description: 'Extension price' + example: 'US$9.95' PriceDescription: - type: "string" - description: "Details about extension pricing" - example: "Price per instance per year" + type: 'string' + description: 'Details about extension pricing' + example: 'Price per instance per year' ShopURL: - type: "string" - description: "URL used to buy the extension" - example: "https://portainer.io/checkout/?add-to-cart=1164" + type: 'string' + description: 'URL used to buy the extension' + example: 'https://portainer.io/checkout/?add-to-cart=1164' UpdateAvailable: - type: "boolean" - description: "Is an update available for this extension" - example: "true" + type: 'boolean' + description: 'Is an update available for this extension' + example: 'true' Version: - type: "string" - description: "Extension version" - example: "1.0.0" + type: 'string' + description: 'Extension version' + example: '1.0.0' License: - $ref: "#/definitions/LicenseInformation" + $ref: '#/definitions/LicenseInformation' ExtensionListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Extension" + $ref: '#/definitions/Extension' ExtensionCreateRequest: - type: "object" + type: 'object' required: - - "License" + - 'License' properties: License: - type: "string" - example: "1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ" - description: "License key" + type: 'string' + example: '1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ' + description: 'License key' ExtensionUpdateRequest: - type: "object" + type: 'object' required: - - "Version" + - 'Version' properties: Version: - type: "string" - example: "1.1.0" - description: "New version of the extension" + type: 'string' + example: '1.1.0' + description: 'New version of the extension' RoleListResponse: - type: "array" + type: 'array' items: - $ref: "#/definitions/Role" + $ref: '#/definitions/Role' Role: - type: "object" + type: 'object' properties: Id: - type: "integer" - description: "Role identifier" + type: 'integer' + description: 'Role identifier' example: 2 Name: - type: "string" - description: "Role name" - example: "HelpDesk" + type: 'string' + description: 'Role name' + example: 'HelpDesk' Description: - type: "string" - description: "Role description" - example: "Read-only access of all resources in an endpoint" + type: 'string' + description: 'Role description' + example: 'Read-only access of all resources in an endpoint' Authorizations: - $ref: "#/definitions/Authorizations" + $ref: '#/definitions/Authorizations' Authorizations: - type: "object" - description: "Authorizations associated to a role" + type: 'object' + description: 'Authorizations associated to a role' additionalProperties: - type: "object" + type: 'object' properties: authorization: - type: "string" + type: 'string' value: - type: "boolean" + type: 'boolean' example: - "DockerContainerList": true - "DockerVolumeList": true + 'DockerContainerList': true + 'DockerVolumeList': true diff --git a/api/swagger_config.json b/api/swagger_config.json index 905e134a3..240c90d30 100644 --- a/api/swagger_config.json +++ b/api/swagger_config.json @@ -1,5 +1,5 @@ { "packageName": "portainer", - "packageVersion": "1.24.0", + "packageVersion": "2.0.0", "projectName": "portainer" } diff --git a/app/__module.js b/app/__module.js index 53c901aab..bc835f9d7 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,7 +1,12 @@ -import '../assets/css/app.css'; +import './assets/css'; +import '@babel/polyfill'; + import angular from 'angular'; -import './agent/_module'; +import './matomo-setup'; +import './assets/js/angulartics-matomo'; + +import './agent'; import './azure/_module'; import './docker/__module'; import './edge/__module'; @@ -12,7 +17,6 @@ angular.module('portainer', [ 'ui.router', 'ui.select', 'isteven-multi-select', - 'ngCookies', 'ngSanitize', 'ngFileUpload', 'ngMessages', @@ -20,7 +24,6 @@ angular.module('portainer', [ 'angularUtils.directives.dirPagination', 'LocalStorageModule', 'angular-jwt', - 'angular-google-analytics', 'angular-json-tree', 'angular-loading-bar', 'angular-clipboard', @@ -30,11 +33,13 @@ angular.module('portainer', [ 'portainer.agent', 'portainer.azure', 'portainer.docker', + 'portainer.kubernetes', 'portainer.edge', - 'portainer.extensions', 'portainer.integrations', 'rzModule', 'moment-picker', + 'angulartics', + 'angulartics.matomo', ]); if (require) { diff --git a/app/agent/components/file-uploader/file-uploader-controller.js b/app/agent/components/file-uploader/file-uploader-controller.js deleted file mode 100644 index d0c5ad798..000000000 --- a/app/agent/components/file-uploader/file-uploader-controller.js +++ /dev/null @@ -1,23 +0,0 @@ -angular.module('portainer.agent').controller('FileUploaderController', [ - '$q', - function FileUploaderController($q) { - var ctrl = this; - - ctrl.state = { - uploadInProgress: false, - }; - - ctrl.onFileSelected = onFileSelected; - - function onFileSelected(file) { - if (!file) { - return; - } - - ctrl.state.uploadInProgress = true; - $q.when(ctrl.uploadFile(file)).finally(function toggleProgress() { - ctrl.state.uploadInProgress = false; - }); - } - }, -]); diff --git a/app/agent/components/file-uploader/file-uploader.js b/app/agent/components/file-uploader/file-uploader.js deleted file mode 100644 index 6232c7f8d..000000000 --- a/app/agent/components/file-uploader/file-uploader.js +++ /dev/null @@ -1,7 +0,0 @@ -angular.module('portainer.agent').component('fileUploader', { - templateUrl: './file-uploader.html', - controller: 'FileUploaderController', - bindings: { - uploadFile: ' diff --git a/app/agent/components/host-browser/hostBrowserController.js b/app/agent/components/host-browser/hostBrowserController.js new file mode 100644 index 000000000..68992fab6 --- /dev/null +++ b/app/agent/components/host-browser/hostBrowserController.js @@ -0,0 +1,159 @@ +import _ from 'lodash-es'; + +const ROOT_PATH = '/host'; + +export class HostBrowserController { + /* @ngInject */ + constructor($async, HostBrowserService, Notifications, FileSaver, ModalService) { + Object.assign(this, { $async, HostBrowserService, Notifications, FileSaver, ModalService }); + + this.state = { + path: ROOT_PATH, + }; + + this.goToParent = this.goToParent.bind(this); + this.browse = this.browse.bind(this); + this.confirmDeleteFile = this.confirmDeleteFile.bind(this); + this.isRoot = this.isRoot.bind(this); + this.getRelativePath = this.getRelativePath.bind(this); + this.getFilesForPath = this.getFilesForPath.bind(this); + this.getFilesForPathAsync = this.getFilesForPathAsync.bind(this); + this.downloadFile = this.downloadFile.bind(this); + this.downloadFileAsync = this.downloadFileAsync.bind(this); + this.renameFile = this.renameFile.bind(this); + this.renameFileAsync = this.renameFileAsync.bind(this); + this.deleteFile = this.deleteFile.bind(this); + this.deleteFileAsync = this.deleteFileAsync.bind(this); + this.onFileSelectedForUpload = this.onFileSelectedForUpload.bind(this); + this.onFileSelectedForUploadAsync = this.onFileSelectedForUploadAsync.bind(this); + } + + getRelativePath(path) { + path = path || this.state.path; + const rootPathRegex = new RegExp(`^${ROOT_PATH}/?`); + const relativePath = path.replace(rootPathRegex, '/'); + return relativePath; + } + + goToParent() { + this.getFilesForPath(this.parentPath(this.state.path)); + } + + isRoot() { + return this.state.path === ROOT_PATH; + } + + browse(folder) { + this.getFilesForPath(this.buildPath(this.state.path, folder)); + } + + getFilesForPath(path) { + return this.$async(this.getFilesForPathAsync, path); + } + async getFilesForPathAsync(path) { + try { + const files = await this.HostBrowserService.ls(path); + this.state.path = path; + this.files = files; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to browse'); + } + } + + renameFile(name, newName) { + return this.$async(this.renameFileAsync, name, newName); + } + async renameFileAsync(name, newName) { + const filePath = this.buildPath(this.state.path, name); + const newFilePath = this.buildPath(this.state.path, newName); + try { + await this.HostBrowserService.rename(filePath, newFilePath); + this.Notifications.success('File successfully renamed', this.getRelativePath(newFilePath)); + const files = await this.HostBrowserService.ls(this.state.path); + this.files = files; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to rename file'); + } + } + + downloadFile(fileName) { + return this.$async(this.downloadFileAsync, fileName); + } + async downloadFileAsync(fileName) { + const filePath = this.buildPath(this.state.path, fileName); + try { + const { file } = await this.HostBrowserService.get(filePath); + const downloadData = new Blob([file], { + type: 'text/plain;charset=utf-8', + }); + this.FileSaver.saveAs(downloadData, fileName); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to download file'); + } + } + + confirmDeleteFile(name) { + const filePath = this.buildPath(this.state.path, name); + + this.ModalService.confirmDeletion(`Are you sure that you want to delete ${this.getRelativePath(filePath)} ?`, (confirmed) => { + if (!confirmed) { + return; + } + return this.deleteFile(filePath); + }); + } + + deleteFile(path) { + this.$async(this.deleteFileAsync, path); + } + async deleteFileAsync(path) { + try { + await this.HostBrowserService.delete(path); + this.Notifications.success('File successfully deleted', this.getRelativePath(path)); + const files = await this.HostBrowserService.ls(this.state.path); + this.files = files; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to delete file'); + } + } + + $onInit() { + this.getFilesForPath(ROOT_PATH); + } + + parentPath(path) { + if (path === ROOT_PATH) { + return ROOT_PATH; + } + + const split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } + + buildPath(parent, file) { + if (parent.lastIndexOf('/') === parent.length - 1) { + return parent + file; + } + return parent + '/' + file; + } + + onFileSelectedForUpload(file) { + return this.$async(this.onFileSelectedForUploadAsync, file); + } + async onFileSelectedForUploadAsync(file) { + try { + await this.HostBrowserService.upload(this.state.path, file); + this.onFileUploaded(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to upload file'); + } + } + + onFileUploaded() { + this.refreshList(); + } + + refreshList() { + this.getFilesForPath(this.state.path); + } +} diff --git a/app/agent/components/host-browser/index.js b/app/agent/components/host-browser/index.js new file mode 100644 index 000000000..d38643d6f --- /dev/null +++ b/app/agent/components/host-browser/index.js @@ -0,0 +1,7 @@ +import angular from 'angular'; +import { HostBrowserController } from './hostBrowserController'; + +angular.module('portainer.agent').component('hostBrowser', { + controller: HostBrowserController, + templateUrl: './hostBrowser.html', +}); diff --git a/app/agent/components/node-selector/node-selector.js b/app/agent/components/node-selector/index.js similarity index 50% rename from app/agent/components/node-selector/node-selector.js rename to app/agent/components/node-selector/index.js index 2f7e14e82..140319c8a 100644 --- a/app/agent/components/node-selector/node-selector.js +++ b/app/agent/components/node-selector/index.js @@ -1,6 +1,10 @@ +import angular from 'angular'; + +import { NodeSelectorController } from './nodeSelectorController'; + angular.module('portainer.agent').component('nodeSelector', { templateUrl: './nodeSelector.html', - controller: 'NodeSelectorController', + controller: NodeSelectorController, bindings: { model: '=', }, diff --git a/app/agent/components/node-selector/nodeSelectorController.js b/app/agent/components/node-selector/nodeSelectorController.js index ce44f8568..f45908886 100644 --- a/app/agent/components/node-selector/nodeSelectorController.js +++ b/app/agent/components/node-selector/nodeSelectorController.js @@ -1,20 +1,18 @@ -angular.module('portainer.agent').controller('NodeSelectorController', [ - 'AgentService', - 'Notifications', - function (AgentService, Notifications) { - var ctrl = this; +export class NodeSelectorController { + /* @ngInject */ + constructor(AgentService, Notifications) { + Object.assign(this, { AgentService, Notifications }); + } - 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'); - }); - }; - }, -]); + async $onInit() { + try { + const agents = await this.AgentService.agents(); + this.agents = agents; + if (!this.model) { + this.model = agents[0].NodeName; + } + } catch (err) { + this.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/index.js similarity index 57% rename from app/agent/components/volume-browser/volume-browser.js rename to app/agent/components/volume-browser/index.js index 5c2b2b78d..e52633a51 100644 --- a/app/agent/components/volume-browser/volume-browser.js +++ b/app/agent/components/volume-browser/index.js @@ -1,6 +1,10 @@ +import angular from 'angular'; + +import { VolumeBrowserController } from './volumeBrowserController'; + angular.module('portainer.agent').component('volumeBrowser', { templateUrl: './volumeBrowser.html', - controller: 'VolumeBrowserController', + controller: VolumeBrowserController, bindings: { volumeId: '<', nodeName: '<', diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 5b85cfd94..c94a285bb 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -9,7 +9,7 @@ browse="$ctrl.browse(name)" rename="$ctrl.rename(name, newName)" download="$ctrl.download(name)" - delete="$ctrl.delete(name)" + delete="$ctrl.confirmDelete(name)" is-upload-allowed="$ctrl.isUploadEnabled" on-file-selected-for-upload="($ctrl.onFileSelectedForUpload)" > diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js index fccde7753..7f6a55e97 100644 --- a/app/agent/components/volume-browser/volumeBrowserController.js +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -1,137 +1,153 @@ 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; - +export class VolumeBrowserController { + /* @ngInject */ + constructor($async, HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService, Notifications) { + Object.assign(this, { $async, HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService, Notifications }); 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 = this.rename.bind(this); + this.renameAsync = this.renameAsync.bind(this); + this.confirmDelete = this.confirmDelete.bind(this); + this.download = this.download.bind(this); + this.downloadAsync = this.downloadAsync.bind(this); + this.up = this.up.bind(this); + this.browse = this.browse.bind(this); + this.deleteFile = this.deleteFile.bind(this); + this.deleteFileAsync = this.deleteFileAsync.bind(this); + this.getFilesForPath = this.getFilesForPath.bind(this); + this.getFilesForPathAsync = this.getFilesForPathAsync.bind(this); + this.onFileSelectedForUpload = this.onFileSelectedForUpload.bind(this); + this.onFileSelectedForUploadAsync = this.onFileSelectedForUploadAsync.bind(this); + this.parentPath = this.parentPath.bind(this); + this.buildPath = this.buildPath.bind(this); + this.$onInit = this.$onInit.bind(this); + this.onFileUploaded = this.onFileUploaded.bind(this); + this.refreshList = this.refreshList.bind(this); + } - 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'); - }); - }; + rename(file, newName) { + return this.$async(this.renameAsync, file, newName); + } + async renameAsync(file, newName) { + const filePath = this.state.path === '/' ? file : `${this.state.path}/${file}`; + const newFilePath = this.state.path === '/' ? newName : `${this.state.path}/${newName}`; - 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; - } - 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'); - }); + try { + await this.VolumeBrowserService.rename(this.volumeId, filePath, newFilePath); + this.Notifications.success('File successfully renamed', newFilePath); + this.files = await this.VolumeBrowserService.ls(this.volumeId, this.state.path); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to rename 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'); - }); - } + confirmDelete(file) { + const filePath = this.state.path === '/' ? file : `${this.state.path}/${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'); - }); - }; - - function parentPath(path) { - if (path.lastIndexOf('/') === 0) { - return '/'; + this.ModalService.confirmDeletion(`Are you sure that you want to delete ${filePath} ?`, (confirmed) => { + if (!confirmed) { + return; } + this.deleteFile(filePath); + }); + } - var split = _.split(path, '/'); - return _.join(_.slice(split, 0, split.length - 1), '/'); + download(file) { + return this.$async(this.downloadAsync, file); + } + async downloadAsync(file) { + const filePath = this.state.path === '/' ? file : `${this.state.path}/${file}`; + + try { + const data = await this.VolumeBrowserService.get(this.volumeId, filePath); + const downloadData = new Blob([data.file]); + this.FileSaver.saveAs(downloadData, file); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to download file'); + } + } + + up() { + const parentFolder = this.parentPath(this.state.path); + this.getFilesForPath(parentFolder); + } + + browse(folder) { + const path = this.buildPath(this.state.path, folder); + this.getFilesForPath(path); + } + + deleteFile(file) { + return this.$async(this.deleteFileAsync, file); + } + async deleteFileAsync(file) { + try { + await this.VolumeBrowserService.delete(this.volumeId, file); + this.Notifications.success('File successfully deleted', file); + this.files = await this.VolumeBrowserService.ls(this.volumeId, this.state.path); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to delete file'); + } + } + + getFilesForPath(path) { + return this.$async(this.getFilesForPathAsync, path); + } + async getFilesForPathAsync(path) { + try { + const files = await this.VolumeBrowserService.ls(this.volumeId, path); + this.state.path = path; + this.files = files; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to browse volume'); + } + } + + onFileSelectedForUpload(file) { + return this.$async(this.onFileSelectedForUploadAsync, file); + } + async onFileSelectedForUploadAsync(file) { + try { + await this.VolumeBrowserService.upload(this.state.path, file, this.volumeId); + this.onFileUploaded(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to upload file'); + } + } + + parentPath(path) { + if (path.lastIndexOf('/') === 0) { + return '/'; } - function buildPath(parent, file) { - if (parent === '/') { - return parent + file; - } - return parent + '/' + file; - } + const split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } - 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(); + buildPath(parent, file) { + if (parent === '/') { + return parent + file; } + return `${parent}/${file}`; + } - function refreshList() { - browse(ctrl.state.path); + onFileUploaded() { + this.refreshList(); + } + + refreshList() { + this.getFilesForPath(this.state.path); + } + + async $onInit() { + this.HttpRequestHelper.setPortainerAgentTargetHeader(this.nodeName); + try { + this.files = await this.VolumeBrowserService.ls(this.volumeId, this.state.path); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to browse volume'); } - }, -]); + } +} diff --git a/app/agent/_module.js b/app/agent/index.js similarity index 54% rename from app/agent/_module.js rename to app/agent/index.js index c74f4eda2..140612a42 100644 --- a/app/agent/_module.js +++ b/app/agent/index.js @@ -1 +1,3 @@ +import angular from 'angular'; + angular.module('portainer.agent', []); diff --git a/app/agent/models/agent.js b/app/agent/models/agent.js index 54866ef90..c41726511 100644 --- a/app/agent/models/agent.js +++ b/app/agent/models/agent.js @@ -1,5 +1,7 @@ -export function AgentViewModel(data) { - this.IPAddress = data.IPAddress; - this.NodeName = data.NodeName; - this.NodeRole = data.NodeRole; +export class AgentViewModel { + constructor(data) { + this.IPAddress = data.IPAddress; + this.NodeName = data.NodeName; + this.NodeRole = data.NodeRole; + } } diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index d7d0c6a2f..00267ce00 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -1,19 +1,16 @@ -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, - }, - { - query: { method: 'GET', isArray: true }, - } - ); - }, -]); +import angular from 'angular'; + +angular.module('portainer.agent').factory('Agent', AgentFactory); + +function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/v:version/agents`, + { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion, + }, + { + query: { method: 'GET', isArray: true }, + } + ); +} diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index 852ee0535..66f8329d5 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -1,39 +1,36 @@ +import angular from 'angular'; + import { browseGetResponse } from './response/browse'; -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, +angular.module('portainer.agent').factory('Browse', BrowseFactory); + +function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { + 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' }, }, - { - 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' }, - }, - } - ); - }, -]); + 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 a5fb198ae..cd79b9763 100644 --- a/app/agent/rest/host.js +++ b/app/agent/rest/host.js @@ -1,19 +1,16 @@ -angular.module('portainer.agent').factory('Host', [ - '$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, - }, - { - info: { method: 'GET', params: { action: 'info' } }, - } - ); - }, -]); +import angular from 'angular'; + +angular.module('portainer.agent').factory('Host', HostFactory); + +function HostFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/v:version/host/:action`, + { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion, + }, + { + info: { method: 'GET', params: { action: 'info' } }, + } + ); +} diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js index b6527dfb4..8726cd8d6 100644 --- a/app/agent/rest/ping.js +++ b/app/agent/rest/ping.js @@ -1,35 +1,32 @@ -angular.module('portainer.agent').factory('AgentPing', [ - '$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, - }, - { - ping: { - method: 'GET', - interceptor: { - response: function versionInterceptor(response) { - var instance = response.resource; - var version = response.headers('Portainer-Agent-Api-Version') || 1; - instance.version = Number(version); - return instance; - }, - responseError: function versionResponseError(error) { - // 404 - agent is up - set version to 1 - if (error.status === 404) { - return { version: 1 }; - } - return $q.reject(error); - }, +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentPing', AgentPingFactory); + +function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/ping`, + { + endpointId: EndpointProvider.endpointID, + }, + { + ping: { + method: 'GET', + interceptor: { + response: function versionInterceptor(response) { + const instance = response.resource; + const version = response.headers('Portainer-Agent-Api-Version') || 1; + instance.version = Number(version); + return instance; + }, + responseError: function versionResponseError(error) { + // 404 - agent is up - set version to 1 + if (error.status === 404) { + return { version: 1 }; + } + return $q.reject(error); }, }, - } - ); - }, -]); + }, + } + ); +} diff --git a/app/agent/rest/response/browse.js b/app/agent/rest/response/browse.js index 32e454305..b88f3212c 100644 --- a/app/agent/rest/response/browse.js +++ b/app/agent/rest/response/browse.js @@ -3,7 +3,7 @@ // This functions simply creates a response object and assign // the data to a field. export function browseGetResponse(data) { - var response = {}; + const response = {}; response.file = data; return response; } diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js index 3d9e8d606..d42a3fb44 100644 --- a/app/agent/rest/v1/agent.js +++ b/app/agent/rest/v1/agent.js @@ -1,17 +1,15 @@ -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 }, - } - ); - }, -]); +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentVersion1', AgentFactory); + +function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/agents`, + { + endpointId: EndpointProvider.endpointID, + }, + { + query: { method: 'GET', isArray: true }, + } + ); +} diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js index 89c18b384..cfc06128c 100644 --- a/app/agent/rest/v1/browse.js +++ b/app/agent/rest/v1/browse.js @@ -1,37 +1,35 @@ +import angular from 'angular'; + 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', BrowseFactory); + +function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/browse/:volumeID/:action`, + { + endpointId: EndpointProvider.endpointID, + }, + { + ls: { + method: 'GET', + isArray: true, + params: { action: 'ls' }, }, - { - 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' }, - }, - } - ); - }, -]); + 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 b9d938d94..579cc104b 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -1,50 +1,35 @@ +import angular from 'angular'; + import { AgentViewModel } from '../models/agent'; -angular.module('portainer.agent').factory('AgentService', [ - '$q', - 'Agent', - 'AgentVersion1', - 'HttpRequestHelper', - 'Host', - 'StateManager', - function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { - 'use strict'; - var service = {}; +angular.module('portainer.agent').factory('AgentService', AgentServiceFactory); - service.agents = agents; - service.hostInfo = hostInfo; +function AgentServiceFactory(Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { + return { + agents, + hostInfo, + }; - function getAgentApiVersion() { - var state = StateManager.getState(); - return state.endpoint.agentApiVersion; + function getAgentApiVersion() { + const state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + + function hostInfo(nodeName) { + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + return Host.info().$promise; + } + + async function agents() { + const agentVersion = getAgentApiVersion(); + const service = agentVersion > 1 ? Agent : AgentVersion1; + try { + const agents = await service.query({ version: agentVersion }).$promise; + return agents.map(function (item) { + return new AgentViewModel(item); + }); + } catch (err) { + throw { msg: 'Unable to retrieve agents', err }; } - - function hostInfo(nodeName) { - HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); - return Host.info().$promise; - } - - function agents() { - var deferred = $q.defer(); - - var agentVersion = getAgentApiVersion(); - var service = agentVersion > 1 ? Agent : AgentVersion1; - - service - .query({ version: agentVersion }) - .$promise.then(function success(data) { - var agents = data.map(function (item) { - return new AgentViewModel(item); - }); - deferred.resolve(agents); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve agents', err: err }); - }); - - return deferred.promise; - } - - return service; - }, -]); + } +} diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js index 8de01f815..fd2d5eed7 100644 --- a/app/agent/services/hostBrowserService.js +++ b/app/agent/services/hostBrowserService.js @@ -1,51 +1,39 @@ -angular.module('portainer.agent').factory('HostBrowserService', [ - 'Browse', - 'Upload', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - '$q', - 'StateManager', - function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) { - var service = {}; +import angular from 'angular'; - service.ls = ls; - service.get = get; - service.delete = deletePath; - service.rename = rename; - service.upload = upload; +angular.module('portainer.agent').factory('HostBrowserService', HostBrowserServiceFactory); - function ls(path) { - return Browse.ls({ path: path }).$promise; - } +function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { + return { ls, get, delete: deletePath, rename, upload }; - function get(path) { - return Browse.get({ path: path }).$promise; - } + function ls(path) { + return Browse.ls({ path: path }).$promise; + } - function deletePath(path) { - return Browse.delete({ path: path }).$promise; - } + function get(path) { + return Browse.get({ path: path }).$promise; + } - function rename(path, newPath) { - var payload = { - CurrentFilePath: path, - NewFilePath: newPath, - }; - return Browse.rename({}, payload).$promise; - } + function deletePath(path) { + return Browse.delete({ path: path }).$promise; + } - 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'; + function rename(path, newPath) { + const payload = { + CurrentFilePath: path, + NewFilePath: newPath, + }; + return Browse.rename({}, payload).$promise; + } + function upload(path, file, onProgress) { + const agentVersion = StateManager.getAgentApiVersion(); + const url = `${API_ENDPOINT_ENDPOINTS}/${EndpointProvider.endpointID()}/docker${agentVersion > 1 ? '/v' + agentVersion : ''}/browse/put`; + + return new Promise((resolve, reject) => { Upload.upload({ url: url, data: { file: file, Path: path }, - }).then(deferred.resolve, deferred.reject, onProgress); - return deferred.promise; - } - - return service; - }, -]); + }).then(resolve, reject, onProgress); + }); + } +} diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js index cc3133f22..bd6380912 100644 --- a/app/agent/services/pingService.js +++ b/app/agent/services/pingService.js @@ -1,14 +1,11 @@ -angular.module('portainer.agent').service('AgentPingService', [ - 'AgentPing', - function AgentPingService(AgentPing) { - var service = {}; +import angular from 'angular'; - service.ping = ping; +angular.module('portainer.agent').service('AgentPingService', AgentPingService); - function ping() { - return AgentPing.ping().$promise; - } +function AgentPingService(AgentPing) { + return { ping }; - return service; - }, -]); + function ping() { + return AgentPing.ping().$promise; + } +} diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 002edb08b..233f5df22 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,61 +1,59 @@ -angular.module('portainer.agent').factory('VolumeBrowserService', [ - 'StateManager', - 'Browse', - 'BrowseVersion1', - '$q', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - 'Upload', - function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { - 'use strict'; - var service = {}; +import angular from 'angular'; - function getAgentApiVersion() { - var state = StateManager.getState(); - return state.endpoint.agentApiVersion; +angular.module('portainer.agent').factory('VolumeBrowserService', VolumeBrowserServiceFactory); + +function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { + return { + ls, + get, + delete: deletePath, + rename, + upload, + }; + + function getAgentApiVersion() { + const state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + + function getBrowseService() { + const agentVersion = getAgentApiVersion(); + return agentVersion > 1 ? Browse : BrowseVersion1; + } + + function ls(volumeId, path) { + return getBrowseService().ls({ volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function get(volumeId, path) { + return getBrowseService().get({ volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function deletePath(volumeId, path) { + return getBrowseService().delete({ volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function rename(volumeId, path, newPath) { + const payload = { + CurrentFilePath: path, + NewFilePath: newPath, + }; + return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; + } + + function upload(path, file, volumeId, onProgress) { + const agentVersion = StateManager.getAgentApiVersion(); + if (agentVersion < 2) { + throw new Error('upload is not supported on this agent version'); } - function getBrowseService() { - var agentVersion = getAgentApiVersion(); - return agentVersion > 1 ? Browse : BrowseVersion1; - } - - service.ls = function (volumeId, path) { - return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; - }; - - service.get = function (volumeId, path) { - return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; - }; - - service.delete = function (volumeId, path) { - return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; - }; - - service.rename = function (volumeId, path, newPath) { - var payload = { - CurrentFilePath: path, - NewFilePath: newPath, - }; - return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; - }; - - service.upload = function upload(path, file, volumeId, onProgress) { - var deferred = $q.defer(); - var agentVersion = StateManager.getAgentApiVersion(); - 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; + const url = `${API_ENDPOINT_ENDPOINTS}/${EndpointProvider.endpointID()}/docker/v${agentVersion}/browse/put?volumeID=${volumeId}`; + return new Promise((resolve, reject) => { Upload.upload({ url: url, - data: { file: file, Path: path }, - }).then(deferred.resolve, deferred.reject, onProgress); - return deferred.promise; - }; - - return service; - }, -]); + data: { file, Path: path }, + }).then(resolve, reject, onProgress); + }); + } +} diff --git a/app/app.js b/app/app.js index 6cec23d30..52ff750b4 100644 --- a/app/app.js +++ b/app/app.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import '@babel/polyfill'; angular.module('portainer').run([ '$rootScope', diff --git a/assets/css/app.css b/app/assets/css/app.css similarity index 92% rename from assets/css/app.css rename to app/assets/css/app.css index f57e3802f..0e77532d6 100644 --- a/assets/css/app.css +++ b/app/assets/css/app.css @@ -75,6 +75,12 @@ input[type='checkbox'] { vertical-align: middle; } +.md-checkbox input[type='checkbox'] { + width: 16px; + height: 16px; + margin-top: -1px; +} + a[ng-click] { cursor: pointer; } @@ -118,7 +124,6 @@ a[ng-click] { .fa.tooltip-icon { margin-left: 5px; font-size: 1.3em; - color: #337ab7; } .fa.green-icon { @@ -151,6 +156,11 @@ a[ng-click] { margin-right: 5px; } +.widget .widget-body table tbody .label-margins { + margin-left: 5px; + margin-right: 0; +} + .widget .widget-body table tbody .fit-text-size { font-size: 90% !important; } @@ -595,10 +605,6 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { padding-left: 0; } -.switch input { - display: none; -} - .small-select { display: inline-block; padding: 0px 6px; @@ -614,17 +620,26 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { margin-left: 21px; } +/* switch box */ +:root { + --switch-size: 24px; +} + +.switch input { + display: none; +} + .switch i, .bootbox-form .checkbox i { display: inline-block; vertical-align: middle; cursor: pointer; - padding-right: 24px; + padding-right: var(--switch-size); transition: all ease 0.2s; -webkit-transition: all ease 0.2s; -moz-transition: all ease 0.2s; -o-transition: all ease 0.2s; - border-radius: 24px; + border-radius: var(--switch-size); box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.5); } @@ -632,9 +647,9 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .bootbox-form .checkbox i:before { display: block; content: ''; - width: 24px; - height: 24px; - border-radius: 24px; + width: var(--switch-size); + height: var(--switch-size); + border-radius: var(--switch-size); background: white; box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.5); } @@ -642,11 +657,19 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .switch :checked + i, .bootbox-form .checkbox :checked ~ i { padding-right: 0; - padding-left: 24px; + padding-left: var(--switch-size); -webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; -moz-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7; } +/* !switch box */ + +/* small switch box */ +.switch.small { + --switch-size: 12px; +} + +/* !small switch box */ .boxselector_wrapper { display: flex; @@ -888,6 +911,17 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { vertical-align: middle; } +.striked::after { + border-bottom: 0.2em solid #777777; + content: ''; + left: 0; + margin-top: calc(0.2em / 2 * -1); + position: absolute; + right: 0; + top: 50%; + z-index: 2; +} + /*bootbox override*/ .modal-open { padding-right: 0 !important; @@ -966,3 +1000,40 @@ json-tree .branch-preview { opacity: 0.5; } /* !json-tree override */ + +/* uib-progressbar override */ +.progress-bar { + color: #4e4e4e; +} +/* !uib-progressbar override */ + +.loading-view-area { + height: 85%; + display: flex; + align-items: center; +} + +/* bootstrap extend */ +.input-xs { + height: 22px; + padding: 2px 5px; + font-size: 12px; + line-height: 1.5; /* If Placeholder of the input is moved up, rem/modify this. */ + border-radius: 3px; +} +/* !bootstrap extend */ + +/* spinkit override */ +.sk-fold { + width: 57px; + height: 57px; +} + +.sk-fold-cube { + background-color: white; +} + +.sk-fold-cube:before { + background-color: #337ab7; +} +/* !spinkit override */ diff --git a/app/assets/css/index.js b/app/assets/css/index.js new file mode 100644 index 000000000..c035bf022 --- /dev/null +++ b/app/assets/css/index.js @@ -0,0 +1,2 @@ +import './rdash.css'; +import './app.css'; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css new file mode 100644 index 000000000..8ee24cd8d --- /dev/null +++ b/app/assets/css/rdash.css @@ -0,0 +1,486 @@ +#content-wrapper { + padding-left: 0; + margin-left: 0; + width: 100%; + height: auto; +} +@media only screen and (min-width: 561px) { + #page-wrapper.open { + padding-left: 250px; + } +} +@media only screen and (max-width: 560px) { + #page-wrapper.open { + padding-left: 70px; + } +} +#page-wrapper.open #sidebar-wrapper { + left: 150px; +} + +/** + * Hamburg Menu + * When the class of 'hamburg' is applied to the body tag of the document, + * the sidebar changes it's style to attempt to mimic a menu on a phone app, + * where the content is overlaying the content, rather than push it. + */ +@media only screen and (max-width: 560px) { + body.hamburg #page-wrapper { + padding-left: 0; + } + body.hamburg #page-wrapper:not(.open) #sidebar-wrapper { + position: absolute; + left: -100px; + } + body.hamburg #page-wrapper:not(.open) ul.sidebar .sidebar-title.separator { + display: none; + } + body.hamburg #page-wrapper.open #sidebar-wrapper { + position: fixed; + } + body.hamburg #page-wrapper.open #sidebar-wrapper ul.sidebar li.sidebar-main { + margin-left: 0px; + } + body.hamburg #sidebar-wrapper ul.sidebar li.sidebar-main, + body.hamburg .row.header .meta { + margin-left: 70px; + } + body.hamburg #sidebar-wrapper ul.sidebar li.sidebar-main, + body.hamburg #page-wrapper.open #sidebar-wrapper ul.sidebar li.sidebar-main { + transition: margin-left 0.4s ease 0s; + } +} + +/** + * Header + */ +.row.header { + height: 60px; + background: #fff; + margin-bottom: 15px; +} +.row.header > div:last-child { + padding-right: 0; +} +.row.header .meta .page { + font-size: 17px; + padding-top: 11px; +} +.row.header .meta .breadcrumb-links { + font-size: 10px; +} +.row.header .meta div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.row.header .login a { + padding: 18px; + display: block; +} +.row.header .user { + min-width: 130px; +} +.row.header .user > .item { + width: 65px; + height: 60px; + float: right; + display: inline-block; + text-align: center; + vertical-align: middle; +} +.row.header .user > .item a { + color: #919191; + display: block; +} +.row.header .user > .item i { + font-size: 20px; + line-height: 55px; +} +.row.header .user > .item img { + width: 40px; + height: 40px; + margin-top: 10px; + border-radius: 2px; +} +.row.header .user > .item ul.dropdown-menu { + border-radius: 2px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05); +} +.row.header .user > .item ul.dropdown-menu .dropdown-header { + text-align: center; +} +.row.header .user > .item ul.dropdown-menu li.link { + text-align: left; +} +.row.header .user > .item ul.dropdown-menu li.link a { + padding-left: 7px; + padding-right: 7px; +} +.row.header .user > .item ul.dropdown-menu:before { + position: absolute; + top: -7px; + right: 23px; + display: inline-block; + border-right: 7px solid transparent; + border-bottom: 7px solid rgba(0, 0, 0, 0.2); + border-left: 7px solid transparent; + content: ''; +} +.row.header .user > .item ul.dropdown-menu:after { + position: absolute; + top: -6px; + right: 24px; + display: inline-block; + border-right: 6px solid transparent; + border-bottom: 6px solid #ffffff; + border-left: 6px solid transparent; + content: ''; +} + +.loading { + width: 40px; + height: 40px; + position: relative; + margin: 100px auto; +} +.double-bounce1, +.double-bounce2 { + width: 100%; + height: 100%; + border-radius: 50%; + background-color: #333; + opacity: 0.6; + position: absolute; + top: 0; + left: 0; + -webkit-animation: bounce 2s infinite ease-in-out; + animation: bounce 2s infinite ease-in-out; +} +.double-bounce2 { + -webkit-animation-delay: -1s; + animation-delay: -1s; +} +@-webkit-keyframes bounce { + 0%, + 100% { + -webkit-transform: scale(0); + } + 50% { + -webkit-transform: scale(1); + } +} +@keyframes bounce { + 0%, + 100% { + transform: scale(0); + -webkit-transform: scale(0); + } + 50% { + transform: scale(1); + -webkit-transform: scale(1); + } +} + +/* Fonts */ +@font-face { + font-family: 'Montserrat'; + src: url('../fonts/montserrat-regular-webfont.eot'); + src: url('../fonts/montserrat-regular-webfont.eot?#iefix') format('embedded-opentype'), url('../fonts/montserrat-regular-webfont.woff') format('woff'), + url('../fonts/montserrat-regular-webfont.ttf') format('truetype'), url('../fonts/montserrat-regular-webfont.svg#montserratregular') format('svg'); + font-weight: normal; + font-style: normal; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + @font-face { + font-family: 'Montserrat'; + src: url('../fonts/montserrat-regular-webfont.svg') format('svg'); + } + select { + font-family: Arial, Helvetica, sans-serif; + } +} +/* Base */ +html { + overflow-y: scroll; +} +body { + background: #f3f3f3; + font-family: 'Montserrat'; + color: #333333 !important; +} +.row { + margin-left: 0 !important; + margin-right: 0 !important; +} +.row > div { + margin-bottom: 15px; +} +.alerts-container .alert:last-child { + margin-bottom: 0; +} +#page-wrapper { + padding-left: 70px; + height: 100%; +} +#sidebar-wrapper { + margin-left: -150px; + left: -30px; + width: 250px; + position: fixed; + height: 100%; + z-index: 999; +} +#page-wrapper, +#sidebar-wrapper { + transition: all 0.4s ease 0s; +} +.green { + background: #23ae89 !important; +} +.blue { + background: #2361ae !important; +} +.orange { + background: #d3a938 !important; +} +.red { + background: #ae2323 !important; +} +.form-group .help-block.form-group-inline-message { + padding-top: 5px; +} +div.input-mask { + padding-top: 7px; +} + +/* #592727 RED */ +/* #2f5927 GREEN */ +/* #30426a BLUE (default)*/ +/* Sidebar background color */ +/* Sidebar header and footer color */ +/* Sidebar title text colour */ +/* Sidebar menu item hover color */ +/** + * Sidebar + */ +#sidebar-wrapper { + background: #30426a; +} +ul.sidebar .sidebar-main a, +.sidebar-footer, +ul.sidebar .sidebar-list a:hover, +#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator { + /* Sidebar header and footer color */ + background: #2d3e63; +} +ul.sidebar { + position: absolute; + top: 0; + bottom: 0; + padding: 0; + margin: 0; + list-style: none; + text-indent: 20px; + overflow-x: hidden; + overflow-y: auto; +} +ul.sidebar li a { + color: #fff; + display: block; + float: left; + text-decoration: none; + width: 250px; +} +ul.sidebar .sidebar-main { + height: 65px; +} +ul.sidebar .sidebar-main a { + font-size: 18px; + line-height: 60px; +} +ul.sidebar .sidebar-main a:hover { + cursor: pointer; +} +ul.sidebar .sidebar-main .menu-icon { + float: right; + font-size: 18px; + padding-right: 28px; + line-height: 60px; +} +ul.sidebar .sidebar-title { + color: #738bc0; + font-size: 12px; + height: 35px; + line-height: 40px; + text-transform: uppercase; + transition: all 0.6s ease 0s; +} +ul.sidebar .sidebar-list { + height: 40px; +} +ul.sidebar .sidebar-list a { + text-indent: 25px; + font-size: 15px; + color: #b2bfdc; + line-height: 40px; +} +ul.sidebar .sidebar-list a:hover { + color: #fff; + border-left: 3px solid #e99d1a; + text-indent: 22px; +} +ul.sidebar .sidebar-list a:hover .menu-icon { + text-indent: 25px; +} +ul.sidebar .sidebar-list .menu-icon { + float: right; + padding-right: 29px; + line-height: 40px; + width: 70px; +} +#page-wrapper:not(.open) ul.sidebar { + bottom: 0; +} +#page-wrapper:not(.open) ul.sidebar .sidebar-title { + display: none; + height: 0px; + text-indent: -100px; +} +#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator { + display: block; + height: 2px; + margin: 13px 0; +} +#page-wrapper:not(.open) ul.sidebar .sidebar-list a:hover span { + border-left: 3px solid #e99d1a; + text-indent: 22px; +} +#page-wrapper:not(.open) .sidebar-footer { + display: none; +} +.sidebar-footer { + position: absolute; + height: 40px; + bottom: 0; + width: 100%; + padding: 0; + margin: 0; + transition: all 0.6s ease 0s; + text-align: center; +} +.sidebar-footer div a { + color: #b2bfdc; + font-size: 12px; + line-height: 43px; +} +.sidebar-footer div a:hover { + color: #ffffff; + text-decoration: none; +} + +/* #592727 RED */ +/* #2f5927 GREEN */ +/* #30426a BLUE (default)*/ +/* Sidebar background color */ +/* Sidebar header and footer color */ +/* Sidebar title text colour */ +/* Sidebar menu item hover color */ + +/** + * Widgets + */ +.widget { + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + background: #ffffff; + border: 1px solid transparent; + border-radius: 2px; + border-color: #e9e9e9; +} +.widget .widget-header .pagination, +.widget .widget-footer .pagination { + margin: 0; +} +.widget .widget-header { + color: #767676; + background-color: #f6f6f6; + padding: 10px 15px; + border-bottom: 1px solid #e9e9e9; + line-height: 30px; +} +.widget .widget-header i { + margin-right: 5px; +} +.widget .widget-body { + padding: 20px; +} +.widget .widget-body table thead { + background: #fafafa; +} +.widget .widget-body table thead * { + font-size: 14px !important; +} +.widget .widget-body table tbody * { + font-size: 13px !important; +} +.widget .widget-body .error { + color: #ff0000; +} +.widget .widget-body button { + margin-left: 5px; +} +.widget .widget-body div.alert { + margin-bottom: 10px; +} +.widget .widget-body.large { + height: 350px; + overflow-y: auto; +} +.widget .widget-body.medium { + height: 250px; + overflow-y: auto; +} +.widget .widget-body.small { + height: 150px; + overflow-y: auto; +} +.widget .widget-body.no-padding { + padding: 0; +} +.widget .widget-body.no-padding .error, +.widget .widget-body.no-padding .message { + padding: 20px; +} +.widget .widget-footer { + border-top: 1px solid #e9e9e9; + padding: 10px; +} +.widget .widget-icon { + background: #30426a; + width: 65px; + height: 65px; + border-radius: 50%; + text-align: center; + vertical-align: middle; + margin-right: 15px; +} +.widget .widget-icon i { + line-height: 66px; + color: #ffffff; + font-size: 30px; +} +.widget .widget-footer { + border-top: 1px solid #e9e9e9; + padding: 10px; +} +.widget .widget-title .pagination, +.widget .widget-footer .pagination { + margin: 0; +} +.widget .widget-content .title { + font-size: 28px; + display: block; +} diff --git a/app/assets/fonts/montserrat-regular-webfont.eot b/app/assets/fonts/montserrat-regular-webfont.eot new file mode 100644 index 000000000..183869665 Binary files /dev/null and b/app/assets/fonts/montserrat-regular-webfont.eot differ diff --git a/app/assets/fonts/montserrat-regular-webfont.svg b/app/assets/fonts/montserrat-regular-webfont.svg new file mode 100644 index 000000000..cb48bdafc --- /dev/null +++ b/app/assets/fonts/montserrat-regular-webfont.svg @@ -0,0 +1,1317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/fonts/montserrat-regular-webfont.ttf b/app/assets/fonts/montserrat-regular-webfont.ttf new file mode 100644 index 000000000..0bdf6e747 Binary files /dev/null and b/app/assets/fonts/montserrat-regular-webfont.ttf differ diff --git a/app/assets/fonts/montserrat-regular-webfont.woff b/app/assets/fonts/montserrat-regular-webfont.woff new file mode 100644 index 000000000..38c80488c Binary files /dev/null and b/app/assets/fonts/montserrat-regular-webfont.woff differ diff --git a/assets/ico/android-chrome-192x192.png b/app/assets/ico/android-chrome-192x192.png similarity index 100% rename from assets/ico/android-chrome-192x192.png rename to app/assets/ico/android-chrome-192x192.png diff --git a/assets/ico/android-chrome-256x256.png b/app/assets/ico/android-chrome-256x256.png similarity index 100% rename from assets/ico/android-chrome-256x256.png rename to app/assets/ico/android-chrome-256x256.png diff --git a/assets/ico/apple-touch-icon.png b/app/assets/ico/apple-touch-icon.png similarity index 100% rename from assets/ico/apple-touch-icon.png rename to app/assets/ico/apple-touch-icon.png diff --git a/assets/ico/browserconfig.xml b/app/assets/ico/browserconfig.xml similarity index 100% rename from assets/ico/browserconfig.xml rename to app/assets/ico/browserconfig.xml diff --git a/assets/ico/favicon-16x16.png b/app/assets/ico/favicon-16x16.png similarity index 100% rename from assets/ico/favicon-16x16.png rename to app/assets/ico/favicon-16x16.png diff --git a/assets/ico/favicon-32x32.png b/app/assets/ico/favicon-32x32.png similarity index 100% rename from assets/ico/favicon-32x32.png rename to app/assets/ico/favicon-32x32.png diff --git a/assets/ico/favicon.ico b/app/assets/ico/favicon.ico similarity index 100% rename from assets/ico/favicon.ico rename to app/assets/ico/favicon.ico diff --git a/assets/ico/manifest.json b/app/assets/ico/manifest.json similarity index 100% rename from assets/ico/manifest.json rename to app/assets/ico/manifest.json diff --git a/assets/ico/mstile-150x150.png b/app/assets/ico/mstile-150x150.png similarity index 100% rename from assets/ico/mstile-150x150.png rename to app/assets/ico/mstile-150x150.png diff --git a/assets/ico/safari-pinned-tab.svg b/app/assets/ico/safari-pinned-tab.svg similarity index 100% rename from assets/ico/safari-pinned-tab.svg rename to app/assets/ico/safari-pinned-tab.svg diff --git a/assets/images/edge_endpoint.png b/app/assets/images/edge_endpoint.png similarity index 100% rename from assets/images/edge_endpoint.png rename to app/assets/images/edge_endpoint.png diff --git a/assets/images/extensions_overview_diagram.png b/app/assets/images/extensions_overview_diagram.png similarity index 100% rename from assets/images/extensions_overview_diagram.png rename to app/assets/images/extensions_overview_diagram.png diff --git a/app/assets/images/kubernetes_edge_endpoint.png b/app/assets/images/kubernetes_edge_endpoint.png new file mode 100644 index 000000000..d4d21f4f8 Binary files /dev/null and b/app/assets/images/kubernetes_edge_endpoint.png differ diff --git a/app/assets/images/kubernetes_endpoint.png b/app/assets/images/kubernetes_endpoint.png new file mode 100644 index 000000000..3a85817cc Binary files /dev/null and b/app/assets/images/kubernetes_endpoint.png differ diff --git a/assets/images/logo.png b/app/assets/images/logo.png similarity index 100% rename from assets/images/logo.png rename to app/assets/images/logo.png diff --git a/assets/images/logo_alt.png b/app/assets/images/logo_alt.png similarity index 100% rename from assets/images/logo_alt.png rename to app/assets/images/logo_alt.png diff --git a/assets/images/logo_ico.png b/app/assets/images/logo_ico.png similarity index 100% rename from assets/images/logo_ico.png rename to app/assets/images/logo_ico.png diff --git a/assets/images/logo_small.png b/app/assets/images/logo_small.png similarity index 100% rename from assets/images/logo_small.png rename to app/assets/images/logo_small.png diff --git a/assets/images/support_1.png b/app/assets/images/support_1.png similarity index 100% rename from assets/images/support_1.png rename to app/assets/images/support_1.png diff --git a/assets/images/support_2.png b/app/assets/images/support_2.png similarity index 100% rename from assets/images/support_2.png rename to app/assets/images/support_2.png diff --git a/app/assets/js/angulartics-matomo.js b/app/assets/js/angulartics-matomo.js new file mode 100644 index 000000000..1b6211ca8 --- /dev/null +++ b/app/assets/js/angulartics-matomo.js @@ -0,0 +1,223 @@ +import angular from 'angular'; + +// forked from https://github.com/angulartics/angulartics-piwik/blob/master/src/angulartics-piwik.js + +/* global _paq */ +/** + * @ngdoc overview + * @name angulartics.piwik + * Enables analytics support for Piwik/Matomo (http://piwik.org/docs/tracking-api/) + */ +angular.module('angulartics.matomo', ['angulartics']).config([ + '$analyticsProvider', + '$windowProvider', + function ($analyticsProvider, $windowProvider) { + var $window = $windowProvider.$get(); + + $analyticsProvider.settings.pageTracking.trackRelativePath = true; + + // Add piwik specific trackers to angulartics API + + // Requires the CustomDimensions plugin for Piwik. + $analyticsProvider.api.setCustomDimension = function (dimensionId, value) { + if ($window._paq) { + $window._paq.push(['setCustomDimension', dimensionId, value]); + } + }; + + // Requires the CustomDimensions plugin for Piwik. + $analyticsProvider.api.deleteCustomDimension = function (dimensionId) { + if ($window._paq) { + $window._paq.push(['deleteCustomDimension', dimensionId]); + } + }; + + // scope: visit or page. Defaults to 'page' + $analyticsProvider.api.setCustomVariable = function (varIndex, varName, value, scope) { + if ($window._paq) { + scope = scope || 'page'; + $window._paq.push(['setCustomVariable', varIndex, varName, value, scope]); + } + }; + + // scope: visit or page. Defaults to 'page' + $analyticsProvider.api.deleteCustomVariable = function (varIndex, scope) { + if ($window._paq) { + scope = scope || 'page'; + $window._paq.push(['deleteCustomVariable', varIndex, scope]); + } + }; + + // trackSiteSearch(keyword, category, [searchCount]) + $analyticsProvider.api.trackSiteSearch = function (keyword, category, searchCount) { + // keyword is required + if ($window._paq && keyword) { + var params = ['trackSiteSearch', keyword, category || false]; + + // searchCount is optional + if (angular.isDefined(searchCount)) { + params.push(searchCount); + } + + $window._paq.push(params); + } + }; + + // logs a conversion for goal 1. revenue is optional + // trackGoal(goalID, [revenue]); + $analyticsProvider.api.trackGoal = function (goalID, revenue) { + if ($window._paq) { + _paq.push(['trackGoal', goalID, revenue || 0]); + } + }; + + // track outlink or download + // linkType is 'link' or 'download', 'link' by default + // trackLink(url, [linkType]); + $analyticsProvider.api.trackLink = function (url, linkType) { + var type = linkType || 'link'; + if ($window._paq) { + $window._paq.push(['trackLink', url, type]); + } + }; + + // Set default angulartics page and event tracking + + $analyticsProvider.registerSetUsername(function (username) { + if ($window._paq) { + $window._paq.push(['setUserId', username]); + } + }); + + // locationObj is the angular $location object + $analyticsProvider.registerPageTrack(function (path) { + if ($window._paq) { + $window._paq.push(['setDocumentTitle', $window.document.title]); + $window._paq.push(['setReferrerUrl', '']); + $window._paq.push(['setCustomUrl', 'http://portainer-ce.app' + path]); + $window._paq.push(['trackPageView']); + } + }); + + /** + * @name eventTrack + * Track a basic event in Piwik, or send an ecommerce event. + * + * @param {string} action A string corresponding to the type of event that needs to be tracked. + * @param {object} properties The properties that need to be logged with the event. + */ + $analyticsProvider.registerEventTrack(function (action, properties) { + if ($window._paq) { + properties = properties || {}; + + switch (action) { + /** + * @description Sets the current page view as a product or category page view. When you call + * setEcommerceView it must be followed by a call to trackPageView to record the product or + * category page view. + * + * @link https://piwik.org/docs/ecommerce-analytics/#tracking-product-page-views-category-page-views-optional + * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce + * + * @property productSKU (required) SKU: Product unique identifier + * @property productName (optional) Product name + * @property categoryName (optional) Product category, or array of up to 5 categories + * @property price (optional) Product Price as displayed on the page + */ + case 'setEcommerceView': + $window._paq.push(['setEcommerceView', properties.productSKU, properties.productName, properties.categoryName, properties.price]); + break; + + /** + * @description Adds a product into the ecommerce order. Must be called for each product in + * the order. + * + * @link https://piwik.org/docs/ecommerce-analytics/#tracking-ecommerce-orders-items-purchased-required + * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce + * + * @property productSKU (required) SKU: Product unique identifier + * @property productName (optional) Product name + * @property categoryName (optional) Product category, or array of up to 5 categories + * @property price (recommended) Product price + * @property quantity (optional, default to 1) Product quantity + */ + case 'addEcommerceItem': + $window._paq.push(['addEcommerceItem', properties.productSKU, properties.productName, properties.productCategory, properties.price, properties.quantity]); + break; + + /** + * @description Tracks a shopping cart. Call this javascript function every time a user is + * adding, updating or deleting a product from the cart. + * + * @link https://piwik.org/docs/ecommerce-analytics/#tracking-add-to-cart-items-added-to-the-cart-optional + * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce + * + * @property grandTotal (required) Cart amount + */ + case 'trackEcommerceCartUpdate': + $window._paq.push(['trackEcommerceCartUpdate', properties.grandTotal]); + break; + + /** + * @description Tracks an Ecommerce order, including any ecommerce item previously added to + * the order. orderId and grandTotal (ie. revenue) are required parameters. + * + * @link https://piwik.org/docs/ecommerce-analytics/#tracking-ecommerce-orders-items-purchased-required + * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce + * + * @property orderId (required) Unique Order ID + * @property grandTotal (required) Order Revenue grand total (includes tax, shipping, and subtracted discount) + * @property subTotal (optional) Order sub total (excludes shipping) + * @property tax (optional) Tax amount + * @property shipping (optional) Shipping amount + * @property discount (optional) Discount offered (set to false for unspecified parameter) + */ + case 'trackEcommerceOrder': + $window._paq.push(['trackEcommerceOrder', properties.orderId, properties.grandTotal, properties.subTotal, properties.tax, properties.shipping, properties.discount]); + break; + + /** + * @description Logs an event with an event category (Videos, Music, Games...), an event + * action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...), and an optional + * event name and optional numeric value. + * + * @link https://piwik.org/docs/event-tracking/ + * @link https://developer.piwik.org/api-reference/tracking-javascript#using-the-tracker-object + * + * @property category + * @property action + * @property name (optional, recommended) + * @property value (optional) + */ + default: + // PAQ requires that eventValue be an integer, see: http://piwik.org/docs/event-tracking + if (properties.value) { + var parsed = parseInt(properties.value, 10); + properties.value = isNaN(parsed) ? 0 : parsed; + } + + $window._paq.push([ + 'trackEvent', + properties.category, + action, + properties.name || properties.label, // Changed in favour of Piwik documentation. Added fallback so it's backwards compatible. + properties.value, + ]); + } + } + }); + + /** + * @name exceptionTrack + * Sugar on top of the eventTrack method for easily handling errors + * + * @param {object} error An Error object to track: error.toString() used for event 'action', error.stack used for event 'label'. + * @param {object} cause The cause of the error given from $exceptionHandler, not used. + */ + $analyticsProvider.registerExceptionTrack(function (error) { + if ($window._paq) { + $window._paq.push(['trackEvent', 'Exceptions', error.toString(), error.stack, 0]); + } + }); + }, +]); diff --git a/app/azure/_module.js b/app/azure/_module.js index a11a5aa5e..30fd789b9 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -6,8 +6,25 @@ angular.module('portainer.azure', ['portainer.app']).config([ var azure = { name: 'azure', url: '/azure', - parent: 'root', + parent: 'endpoint', abstract: true, + onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) { + return $async(async () => { + if (endpoint.Type !== 3) { + $state.go('portainer.home'); + return; + } + try { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + await StateManager.updateEndpointState(endpoint, []); + } catch (e) { + Notifications.error('Failed loading endpoint', e); + $state.go('portainer.home', {}, { reload: true }); + } + }); + }, }; var containerInstances = { @@ -21,6 +38,16 @@ angular.module('portainer.azure', ['portainer.app']).config([ }, }; + var containerInstance = { + name: 'azure.containerinstances.container', + url: '/:id', + views: { + 'content@': { + component: 'containerInstanceDetails', + }, + }, + }; + var containerInstanceCreation = { name: 'azure.containerinstances.new', url: '/new/', @@ -45,6 +72,7 @@ angular.module('portainer.azure', ['portainer.app']).config([ $stateRegistryProvider.register(azure); $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstance); $stateRegistryProvider.register(containerInstanceCreation); $stateRegistryProvider.register(dashboard); }, 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 daec3ef12..1cdf11ca3 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,8 @@ +import angular from 'angular'; + angular.module('portainer.azure').component('azureSidebarContent', { templateUrl: './azureSidebarContent.html', + bindings: { + endpointId: '<', + }, }); diff --git a/app/azure/components/azure-sidebar-content/azureSidebarContent.html b/app/azure/components/azure-sidebar-content/azureSidebarContent.html index 01986e8e7..d6e68d12b 100644 --- a/app/azure/components/azure-sidebar-content/azureSidebarContent.html +++ b/app/azure/components/azure-sidebar-content/azureSidebarContent.html @@ -1,6 +1,6 @@ diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index f9936d78b..da68fd7a0 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -65,8 +65,8 @@ {{ item.Location }} - - :{{ p.port }} + + {{ item.IPAddress }}:{{ p.host }} - diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js index dfc9adeef..af2871c92 100644 --- a/app/azure/models/container_group.js +++ b/app/azure/models/container_group.js @@ -16,11 +16,20 @@ export function ContainerGroupDefaultModel() { } export function ContainerGroupViewModel(data) { + const addressPorts = data.properties.ipAddress.ports; + const container = data.properties.containers.length ? data.properties.containers[0] : {}; + const containerPorts = container ? container.properties.ports : []; + this.Id = data.id; this.Name = data.name; this.Location = data.location; this.IPAddress = data.properties.ipAddress.ip; - this.Ports = data.properties.ipAddress.ports; + this.Ports = addressPorts.length ? addressPorts.map((binding, index) => ({ container: containerPorts[index].port, host: binding.port, protocol: binding.protocol })) : []; + this.Image = container.properties.image || ''; + this.OSType = data.properties.osType; + this.AllocatePublicIP = data.properties.ipAddress.type === 'Public'; + this.CPU = container.properties.resources.requests.cpu; + this.Memory = container.properties.resources.requests.memoryInGB; } export function CreateContainerGroupRequest(model) { @@ -30,6 +39,9 @@ export function CreateContainerGroupRequest(model) { var addressPorts = []; for (var i = 0; i < model.Ports.length; i++) { var binding = model.Ports[i]; + if (!binding.container || !binding.host) { + continue; + } containerPorts.push({ port: binding.container, diff --git a/app/azure/rest/container_group.js b/app/azure/rest/container_group.js index 4dc7a002d..f347be44e 100644 --- a/app/azure/rest/container_group.js +++ b/app/azure/rest/container_group.js @@ -34,12 +34,15 @@ angular.module('portainer.azure').factory('ContainerGroup', [ containerGroupName: '@containerGroupName', }, }, + get: { + method: 'GET', + }, } ); resource.query = base.query; resource.create = withResourceGroup.create; - + resource.get = withResourceGroup.get; return resource; }, ]); diff --git a/app/azure/rest/resource_group.js b/app/azure/rest/resource_group.js index 644279f3b..f1f9b520a 100644 --- a/app/azure/rest/resource_group.js +++ b/app/azure/rest/resource_group.js @@ -5,13 +5,14 @@ angular.module('portainer.azure').factory('ResourceGroup', [ function ResourceGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', + API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups/:resourceGroupName', { endpointId: EndpointProvider.endpointID, 'api-version': '2018-02-01', }, { query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } }, + get: { method: 'GET' }, } ); }, diff --git a/app/azure/rest/subscription.js b/app/azure/rest/subscription.js index 0711d5f92..c5ccea4ca 100644 --- a/app/azure/rest/subscription.js +++ b/app/azure/rest/subscription.js @@ -5,13 +5,14 @@ angular.module('portainer.azure').factory('Subscription', [ function SubscriptionFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions', + API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions/:id', { endpointId: EndpointProvider.endpointID, 'api-version': '2016-06-01', }, { query: { method: 'GET' }, + get: { method: 'GET', params: { id: '@id' } }, } ); }, diff --git a/app/azure/services/containerGroupService.js b/app/azure/services/containerGroupService.js index c99b98ada..1a8c60265 100644 --- a/app/azure/services/containerGroupService.js +++ b/app/azure/services/containerGroupService.js @@ -24,6 +24,12 @@ angular.module('portainer.azure').factory('ContainerGroupService', [ return deferred.promise; }; + service.containerGroup = containerGroup; + async function containerGroup(subscriptionId, resourceGroupName, containerGroupName) { + const containerGroup = await ContainerGroup.get({ subscriptionId, resourceGroupName, containerGroupName }).$promise; + return new ContainerGroupViewModel(containerGroup); + } + service.create = function (model, subscriptionId, resourceGroupName) { var payload = new CreateContainerGroupRequest(model); return ContainerGroup.create( diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js index 4110835f4..9f27dc537 100644 --- a/app/azure/services/resourceGroupService.js +++ b/app/azure/services/resourceGroupService.js @@ -24,6 +24,12 @@ angular.module('portainer.azure').factory('ResourceGroupService', [ return deferred.promise; }; + service.resourceGroup = resourceGroup; + async function resourceGroup(subscriptionId, resourceGroupName) { + const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise; + return new ResourceGroupViewModel(group); + } + return service; }, ]); diff --git a/app/azure/services/subscriptionService.js b/app/azure/services/subscriptionService.js index 3b22ac664..bfbcfeb61 100644 --- a/app/azure/services/subscriptionService.js +++ b/app/azure/services/subscriptionService.js @@ -24,6 +24,12 @@ angular.module('portainer.azure').factory('SubscriptionService', [ return deferred.promise; }; + service.subscription = subscription; + async function subscription(id) { + const subscription = await Subscription.get({ id }).$promise; + return new SubscriptionViewModel(subscription); + } + return service; }, ]); diff --git a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html new file mode 100644 index 000000000..37940a149 --- /dev/null +++ b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetails.html @@ -0,0 +1,134 @@ + + + Container instances > {{ $ctrl.container.Name }} + + +
+
+ + +
+
+ Azure settings +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ Container configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+ +
+ +
+
+ +
+ Container resources +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+
+
+
+
diff --git a/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js new file mode 100644 index 000000000..c723a8b88 --- /dev/null +++ b/app/azure/views/containerinstances/container-instance-details/containerInstanceDetailsController.js @@ -0,0 +1,36 @@ +class ContainerInstanceDetailsController { + /* @ngInject */ + constructor($state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService) { + Object.assign(this, { $state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService }); + + this.state = { + loading: false, + }; + + this.container = null; + this.subscription = null; + this.resourceGroup = null; + } + + async $onInit() { + this.state.loading = true; + const { id } = this.$state.params; + const { subscriptionId, resourceGroupId, containerGroupId } = parseId(id); + try { + this.subscription = await this.SubscriptionService.subscription(subscriptionId); + this.container = await this.ContainerGroupService.containerGroup(subscriptionId, resourceGroupId, containerGroupId); + this.resourceGroup = await this.ResourceGroupService.resourceGroup(subscriptionId, resourceGroupId); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrive container instance details'); + } + this.state.loading = false; + } +} + +function parseId(id) { + const [, subscriptionId, resourceGroupId, , containerGroupId] = id.match(/^\/subscriptions\/(.+)\/resourceGroups\/(.+)\/providers\/(.+)\/containerGroups\/(.+)$/); + + return { subscriptionId, resourceGroupId, containerGroupId }; +} + +export default ContainerInstanceDetailsController; diff --git a/app/azure/views/containerinstances/container-instance-details/index.js b/app/azure/views/containerinstances/container-instance-details/index.js new file mode 100644 index 000000000..8e3b3d179 --- /dev/null +++ b/app/azure/views/containerinstances/container-instance-details/index.js @@ -0,0 +1,6 @@ +import ContainerInstanceDetailsController from './containerInstanceDetailsController.js'; + +angular.module('portainer.azure').component('containerInstanceDetails', { + templateUrl: './containerInstanceDetails.html', + controller: ContainerInstanceDetailsController, +}); diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html index 6c0223852..cfa2337c9 100644 --- a/app/azure/views/containerinstances/containerinstances.html +++ b/app/azure/views/containerinstances/containerinstances.html @@ -11,7 +11,7 @@
!port.host || !port.container)) { + return 'At least one port binding is required'; + } + + return null; + } + function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) { $scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0]; $scope.resourceGroups = resourceGroups[subscription.Id]; diff --git a/app/azure/views/containerinstances/create/createcontainerinstance.html b/app/azure/views/containerinstances/create/createcontainerinstance.html index 625e50bc5..477fb845a 100644 --- a/app/azure/views/containerinstances/create/createcontainerinstance.html +++ b/app/azure/views/containerinstances/create/createcontainerinstance.html @@ -7,7 +7,7 @@
-
+
Azure settings
@@ -53,7 +53,14 @@
- + +
+
+
+
+
+

Name is required.

+
@@ -61,7 +68,14 @@
- + +
+
+
+
+
+

Image is required.

+
@@ -120,14 +134,10 @@
-
- - -
+
This will automatically deploy a container with a public IP address
+
Container resources
@@ -157,6 +167,7 @@ Deploy the container Deployment in progress... + {{ state.formValidationError }}
diff --git a/app/config.js b/app/config.js index 0f8d8234e..5e74a916d 100644 --- a/app/config.js +++ b/app/config.js @@ -7,11 +7,10 @@ angular.module('portainer').config([ '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', - 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', 'cfpLoadingBarProvider', - function ($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { + function ($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { 'use strict'; var environment = '@@ENVIRONMENT'; @@ -52,9 +51,6 @@ angular.module('portainer').config([ }, ]); - AnalyticsProvider.setAccount({ tracker: __CONFIG_GA_ID, set: { anonymizeIp: true } }); - AnalyticsProvider.startOffline(true); - toastr.options.timeOut = 3000; Terminal.applyAddon(fit); diff --git a/app/constants.js b/app/constants.js index e615db918..3dba966dc 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,16 +2,16 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') + .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') .constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks') .constant('API_ENDPOINT_EDGE_TEMPLATES', 'api/edge_templates') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') - .constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') - .constant('API_ENDPOINT_SCHEDULES', 'api/schedules') .constant('API_ENDPOINT_SETTINGS', 'api/settings') .constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') @@ -26,4 +26,5 @@ angular .constant('PAGINATION_MAX_ITEMS', 10) .constant('APPLICATION_CACHE_VALIDITY', 3600) .constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.') - .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']); + .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']) + .constant('KUBERNETES_SYSTEM_NAMESPACES', ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']); diff --git a/app/docker/__module.js b/app/docker/__module.js index 8a1ecdaa3..33495ab47 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -5,19 +5,59 @@ angular.module('portainer.docker', ['portainer.app']).config([ var docker = { name: 'docker', - parent: 'root', + parent: 'endpoint', + url: '/docker', abstract: true, - resolve: { - endpointID: [ - 'EndpointProvider', - '$state', - function (EndpointProvider, $state) { - var id = EndpointProvider.endpointID(); - if (!id) { - return $state.go('portainer.home'); + onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, EndpointProvider, LegacyExtensionManager, Notifications, StateManager, SystemService) { + return $async(async () => { + if (![1, 2, 4].includes(endpoint.Type)) { + $state.go('portainer.home'); + return; + } + try { + const status = await checkEndpointStatus(endpoint); + + if (endpoint.Type !== 4) { + await updateEndpointStatus(endpoint, status); } - }, - ], + endpoint.Status = status; + + if (status === 2) { + if (!endpoint.Snapshots[0]) { + throw new Error('Endpoint is unreachable and there is no snapshot available for offline browsing.'); + } + if (endpoint.Snapshots[0].Swarm) { + throw new Error('Endpoint is unreachable. Connect to another swarm manager.'); + } + } + + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + + const extensions = await LegacyExtensionManager.initEndpointExtensions(endpoint); + await StateManager.updateEndpointState(endpoint, extensions); + } catch (e) { + Notifications.error('Failed loading endpoint', e); + $state.go('portainer.home', {}, { reload: true }); + } + + async function checkEndpointStatus(endpoint) { + try { + await SystemService.ping(endpoint.Id); + return 1; + } catch (e) { + return 2; + } + } + + async function updateEndpointStatus(endpoint, status) { + if (endpoint.Status === status) { + return; + } + await EndpointService.updateEndpoint(endpoint.Id, { Status: status }); + } + }); }, }; @@ -144,6 +184,43 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + const customTemplates = { + name: 'docker.templates.custom', + url: '/custom', + + views: { + 'content@': { + component: 'customTemplatesView', + }, + }, + }; + + const customTemplatesNew = { + name: 'docker.templates.custom.new', + url: '/new?fileContent&type', + + views: { + 'content@': { + component: 'createCustomTemplateView', + }, + }, + params: { + fileContent: '', + type: '', + }, + }; + + const customTemplatesEdit = { + name: 'docker.templates.custom.edit', + url: '/:id', + + views: { + 'content@': { + component: 'editCustomTemplateView', + }, + }, + }; + var dashboard = { name: 'docker.dashboard', url: '/dashboard', @@ -175,16 +252,6 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; - var hostJob = { - name: 'docker.host.job', - url: '/job', - views: { - 'content@': { - component: 'hostJobView', - }, - }, - }; - var events = { name: 'docker.events', url: '/events', @@ -299,16 +366,6 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; - var nodeJob = { - name: 'docker.nodes.node.job', - url: '/job', - views: { - 'content@': { - component: 'nodeJobView', - }, - }, - }; - var secrets = { name: 'docker.secrets', url: '/secrets', @@ -386,6 +443,39 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + var stacks = { + name: 'docker.stacks', + url: '/stacks', + views: { + 'content@': { + templateUrl: '~Portainer/views/stacks/stacks.html', + controller: 'StacksController', + }, + }, + }; + + var stack = { + name: 'docker.stacks.stack', + url: '/:name?id&type&external', + views: { + 'content@': { + templateUrl: '~Portainer/views/stacks/edit/stack.html', + controller: 'StackController', + }, + }, + }; + + var stackCreation = { + name: 'docker.stacks.newstack', + url: '/newstack', + views: { + 'content@': { + templateUrl: '~Portainer/views/stacks/create/createstack.html', + controller: 'CreateStackController', + }, + }, + }; + var swarm = { name: 'docker.swarm', url: '/swarm', @@ -436,6 +526,17 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + var templates = { + name: 'docker.templates', + url: '/templates', + views: { + 'content@': { + templateUrl: '~Portainer/views/templates/templates.html', + controller: 'TemplatesController', + }, + }, + }; + var volumes = { name: 'docker.volumes', url: '/volumes', @@ -491,11 +592,13 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(containerInspect); $stateRegistryProvider.register(containerLogs); $stateRegistryProvider.register(containerStats); + $stateRegistryProvider.register(customTemplates); + $stateRegistryProvider.register(customTemplatesNew); + $stateRegistryProvider.register(customTemplatesEdit); $stateRegistryProvider.register(docker); $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(host); $stateRegistryProvider.register(hostBrowser); - $stateRegistryProvider.register(hostJob); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); @@ -507,7 +610,6 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(nodes); $stateRegistryProvider.register(node); $stateRegistryProvider.register(nodeBrowser); - $stateRegistryProvider.register(nodeJob); $stateRegistryProvider.register(secrets); $stateRegistryProvider.register(secret); $stateRegistryProvider.register(secretCreation); @@ -515,11 +617,15 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(service); $stateRegistryProvider.register(serviceCreation); $stateRegistryProvider.register(serviceLogs); + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stack); + $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(swarm); $stateRegistryProvider.register(swarmVisualizer); $stateRegistryProvider.register(tasks); $stateRegistryProvider.register(task); $stateRegistryProvider.register(taskLogs); + $stateRegistryProvider.register(templates); $stateRegistryProvider.register(volumes); $stateRegistryProvider.register(volume); $stateRegistryProvider.register(volumeBrowse); diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index a6b8aa6a7..8939b44be 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -90,7 +90,7 @@ - + Ownership @@ -112,7 +112,7 @@ {{ item.Name }} {{ item.CreatedAt | getisodate }} - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.js b/app/docker/components/datatables/configs-datatable/configsDatatable.js index 0e75d7013..569281fcc 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.js +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.js @@ -8,7 +8,6 @@ angular.module('portainer.docker').component('configsDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - showOwnershipColumn: '<', removeAction: '<', refreshCallback: '<', }, diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js index 82839c40f..d2edde7b1 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js @@ -1,81 +1,82 @@ import _ from 'lodash-es'; -angular.module('portainer.docker') - .controller('ContainerNetworksDatatableController', ['$scope', '$controller', 'DatatableService', - function ($scope, $controller, DatatableService) { +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, + }); - 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.expandItem = function (item, expanded) { + if (!this.itemCanExpand(item)) { + return; } - this.hasExpandableItems = function () { - return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length; - }; + item.Expanded = expanded; + if (!expanded) { + item.Highlighted = false; + } + if (!item.Expanded) { + this.state.expandAll = false; + } + }; - this.expandAll = function () { - this.state.expandAll = !this.state.expandAll; - _.forEach(this.dataset, (item) => { - if (this.itemCanExpand(item)) { - this.expandItem(item, this.state.expandAll); - } - }); - }; + this.itemCanExpand = function (item) { + return item.GlobalIPv6Address !== ''; + }; - this.$onInit = function () { - this.setDefaults(); - this.prepareTableFromDataset(); + this.hasExpandableItems = function () { + return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length; + }; - this.state.orderBy = this.orderBy; - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; + this.expandAll = function () { + this.state.expandAll = !this.state.expandAll; + _.forEach(this.dataset, (item) => { + if (this.itemCanExpand(item)) { + this.expandItem(item, this.state.expandAll); } + }); + }; - var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } + this.$onInit = function () { + this.setDefaults(); + this.prepareTableFromDataset(); - var storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } + 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 storedSettings = DatatableService.getDataTableSettings(this.tableKey); - if (storedSettings !== null) { - this.settings = storedSettings; - this.settings.open = false; - } + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } - _.forEach(this.dataset, (item) => { - item.Expanded = true; - item.Highlighted = true; - }); - }; - } - ]); \ No newline at end of file + 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; + }); + }; + }, +]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 44aa27c7e..9ba136c4c 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -20,39 +20,75 @@ @@ -246,7 +282,7 @@ - + Ownership @@ -319,7 +355,7 @@ - - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index e136cf191..0edc97de0 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -8,7 +8,6 @@ angular.module('portainer.docker').component('containersDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - showOwnershipColumn: '<', showHostColumn: '<', showAddAction: '<', offlineMode: '<', diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index aeb098889..cc02ab678 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -60,10 +60,6 @@ angular.module('portainer.docker').controller('ContainersDatatableController', [ label: 'Created', display: true, }, - ip: { - label: 'IP Address', - display: true, - }, host: { label: 'Host', display: true, @@ -79,8 +75,8 @@ angular.module('portainer.docker').controller('ContainersDatatableController', [ }, }; - this.onColumnVisibilityChange = function () { - DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility); + this.onColumnVisibilityChange = function (columnVisibility) { + DatatableService.setColumnVisibilitySettings(this.tableKey, columnVisibility); }; this.onSelectionChanged = function () { diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html deleted file mode 100644 index 16e44d5e0..000000000 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html +++ /dev/null @@ -1,130 +0,0 @@ -
-
-
- - -
-
- - {{ $ctrl.titleText }} -
-
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - -
- - Id - - - - - - State - - - -
- Filter - Filter -
- -
- - - - Created - -
- {{ item | containername }} - - {{ item.Status }} - - {{ item.Status }} - - {{ item.Created | getisodatefromtimestamp }} -
Loading...
No jobs available.
-
- -
-
-
-
-
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js deleted file mode 100644 index 337f8e1b7..000000000 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js +++ /dev/null @@ -1,12 +0,0 @@ -angular.module('portainer.docker').component('jobsDatatable', { - templateUrl: './jobsDatatable.html', - controller: 'JobsDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - }, -}); diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js deleted file mode 100644 index 4a5a68048..000000000 --- a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js +++ /dev/null @@ -1,150 +0,0 @@ -import _ from 'lodash-es'; - -angular.module('portainer.docker').controller('JobsDatatableController', [ - '$scope', - '$controller', - '$q', - '$state', - 'PaginationService', - 'DatatableService', - 'ContainerService', - 'ModalService', - 'Notifications', - function ($scope, $controller, $q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - - var ctrl = this; - - this.filters = { - state: { - open: false, - enabled: false, - values: [], - }, - }; - - this.applyFilters = function (value) { - var container = value; - var filters = ctrl.filters; - for (var i = 0; i < filters.state.values.length; i++) { - var filter = filters.state.values[i]; - if (container.Status === filter.label && filter.display) { - return true; - } - } - return false; - }; - - this.onStateFilterChange = function () { - var filters = this.filters.state.values; - var filtered = false; - for (var i = 0; i < filters.length; i++) { - var filter = filters[i]; - if (!filter.display) { - filtered = true; - } - } - this.filters.state.enabled = filtered; - DatatableService.setDataTableFilters(this.tableKey, this.filters); - }; - - this.prepareTableFromDataset = function () { - var availableStateFilters = []; - for (var i = 0; i < this.dataset.length; i++) { - var item = this.dataset[i]; - availableStateFilters.push({ - label: item.Status, - display: true, - }); - } - this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); - }; - - this.updateStoredFilters = function (storedFilters) { - var datasetFilters = this.filters.state.values; - - for (var i = 0; i < datasetFilters.length; i++) { - var filter = datasetFilters[i]; - var existingFilter = _.find(storedFilters, ['label', filter.label]); - if (existingFilter && !existingFilter.display) { - filter.display = existingFilter.display; - this.filters.state.enabled = true; - } - } - }; - - function confirmPurgeJobs() { - return showConfirmationModal(); - - function showConfirmationModal() { - var deferred = $q.defer(); - - ModalService.confirm({ - title: 'Are you sure ?', - message: 'Clearing job history will remove all stopped jobs containers.', - buttons: { - confirm: { - label: 'Purge', - className: 'btn-danger', - }, - }, - callback: function onConfirm(confirmed) { - deferred.resolve(confirmed); - }, - }); - - return deferred.promise; - } - } - - this.purgeAction = function () { - confirmPurgeJobs().then(function success(confirmed) { - if (!confirmed) { - return $q.when(); - } - ContainerService.prune({ label: ['io.portainer.job.endpoint'] }) - .then(function success() { - Notifications.success('Success', 'Job history cleared'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err.message, 'Unable to clear job history'); - }); - }); - }; - - 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; - this.updateStoredFilters(storedFilters.state.values); - } - 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; - } - this.onSettingsRepeaterChange(); - }; - }, -]); diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 7dd2d87b1..1f9e5874a 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -120,11 +120,11 @@ diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html index 04fb4d94d..b2152f746 100644 --- a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html +++ b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html @@ -28,7 +28,7 @@ {{ item.IPAM.IPV6Configs[0].Subnet ? item.IPAM.IPV6Configs[0].Subnet : '-' }} {{ item.IPAM.IPV6Configs[0].Gateway ? item.IPAM.IPV6Configs[0].Gateway : '-' }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 0c8f1e905..bf54ca837 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -151,7 +151,7 @@ - + Ownership diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index a46961009..20d48108b 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -8,7 +8,6 @@ angular.module('portainer.docker').component('networksDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - showOwnershipColumn: '<', showHostColumn: '<', removeAction: '<', offlineMode: '<', diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index e804e738a..f7d9b0c6e 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -90,7 +90,7 @@ - + Ownership @@ -112,7 +112,7 @@ {{ item.Name }} {{ item.CreatedAt | getisodate }} - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js index c3f0c0ed7..840a98fc2 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js @@ -8,7 +8,6 @@ angular.module('portainer.docker').component('secretsDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - showOwnershipColumn: '<', removeAction: '<', refreshCallback: '<', }, diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index c5fbc3dff..3e72d09f4 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -115,7 +115,7 @@ - + Ownership @@ -180,7 +180,7 @@ - {{ item.UpdatedAt | getisodate }} - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.js b/app/docker/components/datatables/services-datatable/servicesDatatable.js index 7f8731892..6e5bbfb77 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.js @@ -10,7 +10,6 @@ angular.module('portainer.docker').component('servicesDatatable', { reverseOrder: '<', nodes: '<', agentProxy: '<', - showOwnershipColumn: '<', showUpdateAction: '<', showAddAction: '<', showStackColumn: '<', diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 12a9c776f..bd2c4e6b7 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -93,11 +93,11 @@ @@ -142,7 +142,7 @@ - + Ownership @@ -177,7 +177,7 @@ {{ item.Mountpoint | truncatelr }} {{ item.CreatedAt | getisodate }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index af00b13ce..0ee1524e3 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -8,7 +8,6 @@ angular.module('portainer.docker').component('volumesDatatable', { tableKey: '@', orderBy: '@', reverseOrder: '<', - showOwnershipColumn: '<', showHostColumn: '<', removeAction: '<', showBrowseAction: '<', diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js index b57e4cdcb..088165ac3 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js @@ -6,5 +6,9 @@ angular.module('portainer.docker').component('dockerSidebarContent', { standaloneManagement: '<', adminAccess: '<', offlineMode: '<', + toggle: '<', + currentRouteName: '<', + endpointId: '<', + showStacks: '<', }, }); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index c5aec5434..5af425761 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -1,39 +1,43 @@ - diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html index bbed25466..fb87c0fa5 100644 --- a/app/docker/components/host-overview/host-overview.html +++ b/app/docker/components/host-overview/host-overview.html @@ -13,22 +13,10 @@ host="$ctrl.hostDetails" is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" browse-url="{{ $ctrl.browseUrl }}" - is-job-enabled="$ctrl.isJobEnabled && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" - job-url="{{ $ctrl.jobUrl }}" > - - diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js index de7410144..d7fa2b119 100644 --- a/app/docker/components/host-overview/host-overview.js +++ b/app/docker/components/host-overview/host-overview.js @@ -10,10 +10,7 @@ angular.module('portainer.docker').component('hostOverview', { agentApiVersion: '<', refreshUrl: '@', browseUrl: '@', - jobUrl: '@', - isJobEnabled: '<', hostFeaturesEnabled: '<', - jobs: '<', }, transclude: true, }); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html index 24ba17e70..13f5a0eae 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -25,14 +25,11 @@ Total memory {{ $ctrl.host.totalMemory | humansize }} - + - - diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js index 693a022b7..7fd43efc0 100644 --- a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -2,9 +2,7 @@ angular.module('portainer.docker').component('hostDetailsPanel', { templateUrl: './host-details-panel.html', bindings: { host: '<', - isJobEnabled: '<', isBrowseEnabled: '<', browseUrl: '@', - jobUrl: '@', }, }); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html index e26ec168c..4e2dff80b 100644 --- a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html @@ -2,32 +2,18 @@ There are no labels for this node. - - - - - - - - - - - - - -
LabelValue
-
- Name - -
-
-
- Value - - - - -
-
+
+
+
+ name + +
+
+ value + +
+ +
+
diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html index 225d820e9..2a0cd5143 100644 --- a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html @@ -45,7 +45,7 @@ - +